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_and_compatibility_with_prefill,
9        render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill,
10        ChatTemplateOptions, ModelChatTemplate, ModelReasoningProtocol, ReasoningEffort,
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_bench_core::{
26    BenchmarkRequestCorrelation, BENCHMARK_CELL_ID_HEADER, BENCHMARK_PHASE_HEADER,
27    BENCHMARK_REPEAT_INDEX_HEADER, BENCHMARK_REQUEST_INDEX_HEADER, BENCHMARK_RUN_ID_HEADER,
28};
29use ferrum_interfaces::engine::{EmbedEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine};
30use ferrum_types::{
31    has_unclosed_model_reasoning_block, model_reasoning_markers,
32    parse_harmony_response_for_finish_reason, parse_model_reasoning_response,
33    should_defer_model_reasoning_stream_delta, EngineMetrics, EngineStatus, FerrumConfigBuilder,
34    FerrumError as Error, FerrumProfileEvent, FinishReason, InferenceExecutionEvidence,
35    InferenceRequest, InferenceResponse, ModelId, ModelOutputProtocol, NativeChatOutputProjector,
36    ParsedReasoningResponse, Priority, ProcessMemoryObservation, ProcessMemorySample,
37    ProcessMemorySampler, ProfileEntrypoint, ProfileError, ProfileEventKind, ProfileStatus,
38    ReplayReference, RequestId, ResolvedFerrumConfig, ResourceAction, ResourceTraceEvent,
39    ResponseCompletionBoundary, RuntimeConfigSnapshot, SamplingParams, StructuredOutputStart,
40    TokenId, TokenUsage, DEFAULT_CHAT_REPETITION_PENALTY, DEFAULT_MAX_TOKENS_METADATA_KEY,
41    OBSERVABILITY_PROFILE_SCHEMA_VERSION, PROMPT_OPENED_REASONING_METADATA_KEY, THINK_END_TAG,
42    THINK_START_TAG,
43};
44use sha2::{Digest, Sha256};
45use std::{
46    collections::{BTreeMap, HashMap},
47    error::Error as StdError,
48    fs,
49    path::{Path, PathBuf},
50    sync::{
51        atomic::{AtomicBool, Ordering},
52        Arc, Mutex, OnceLock,
53    },
54    time::Instant,
55};
56use tokio::sync::{mpsc, Notify};
57use tokio_stream::StreamExt;
58use tower::ServiceBuilder;
59use tower_http::{cors::CorsLayer, trace::TraceLayer};
60use tracing::{debug, error, info, span, warn, Level};
61use uuid::Uuid;
62
63mod responses;
64
65const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
66const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
67const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 4096;
68const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
69const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
70const MAX_CACHED_JSON_SCHEMA_VALIDATORS: usize = 64;
71const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
72    &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
73const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
74static JSON_SCHEMA_VALIDATOR_CACHE: OnceLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
75    OnceLock::new();
76
77/// Product defaults used when an OpenAI chat request omits sampling fields.
78/// The engine composition root consumes the same typed value so its resolved
79/// startup plan cannot describe different defaults from the HTTP endpoint.
80pub fn default_chat_sampling_params() -> SamplingParams {
81    SamplingParams {
82        max_tokens: DEFAULT_COMPLETION_MAX_TOKENS as usize,
83        temperature: DEFAULT_SAMPLING_TEMPERATURE,
84        top_p: DEFAULT_SAMPLING_TOP_P,
85        repetition_penalty: DEFAULT_CHAT_REPETITION_PENALTY,
86        ..SamplingParams::default()
87    }
88}
89
90#[derive(Debug, Clone)]
91struct CachePolicy {
92    prefix_cache_enabled: bool,
93    session_cache_mode: String,
94    session_cache_max_entries: usize,
95    session_cache_max_tokens: usize,
96}
97
98impl CachePolicy {
99    fn current() -> Self {
100        Self {
101            prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
102                .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
103                .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
104                .unwrap_or(false),
105            session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
106                .unwrap_or_else(|_| "off".to_string())
107                .to_ascii_lowercase(),
108            session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
109            session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
110        }
111    }
112
113    fn session_memory_enabled(&self) -> bool {
114        self.session_cache_mode == "memory"
115    }
116}
117
118fn env_bool(key: &str) -> Option<bool> {
119    match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
120        "1" | "true" | "yes" | "on" => Some(true),
121        "0" | "false" | "no" | "off" => Some(false),
122        _ => None,
123    }
124}
125
126fn env_usize(key: &str) -> Option<usize> {
127    std::env::var(key).ok()?.parse().ok()
128}
129
130/// Shared Prometheus recorder handle for rendering metrics.
131static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
132    std::sync::OnceLock::new();
133
134/// Initialize the Prometheus metrics recorder.
135///
136/// Must be called once before any `metrics::counter!()` / `histogram!()` calls.
137/// Safe to call multiple times — subsequent calls are no-ops.
138pub fn init_prometheus_recorder() {
139    PROM_HANDLE.get_or_init(|| {
140        let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
141        let handle = builder
142            .install_recorder()
143            .expect("Failed to install Prometheus recorder");
144        info!("Prometheus metrics recorder installed");
145        handle
146    });
147}
148
149/// Axum-based server implementation.
150///
151/// The server is built around [`AppState`], which holds an optional
152/// engine per modality. Handlers fault to 503 when the modality they
153/// need isn't loaded, instead of running stub error logic.
154pub struct AxumServer {
155    state: AppState,
156    config: ServerConfig,
157    lifecycle: Arc<AxumServerLifecycle>,
158}
159
160#[derive(Default)]
161struct AxumServerLifecycle {
162    shutdown_requested: AtomicBool,
163    running: AtomicBool,
164    engines_stopped: AtomicBool,
165    shutdown_notify: Notify,
166    stopped_notify: Notify,
167    stop_lock: tokio::sync::Mutex<()>,
168}
169
170impl AxumServerLifecycle {
171    fn request_shutdown(&self) {
172        self.shutdown_requested.store(true, Ordering::Release);
173        self.shutdown_notify.notify_waiters();
174    }
175
176    async fn wait_for_shutdown(&self) {
177        while !self.shutdown_requested.load(Ordering::Acquire) {
178            self.shutdown_notify.notified().await;
179        }
180    }
181
182    async fn wait_until_stopped(&self) {
183        while self.running.load(Ordering::Acquire) {
184            self.stopped_notify.notified().await;
185        }
186    }
187}
188
189struct AxumServerRunGuard {
190    lifecycle: Arc<AxumServerLifecycle>,
191}
192
193impl Drop for AxumServerRunGuard {
194    fn drop(&mut self) {
195        self.lifecycle.running.store(false, Ordering::Release);
196        self.lifecycle.stopped_notify.notify_waiters();
197    }
198}
199
200fn single_model_registry(engine_model_id: ModelId, kind: ServedModelKind) -> ServedModelRegistry {
201    let public_name = engine_model_id.to_string();
202    ServedModelRegistry::try_new(engine_model_id, kind, vec![public_name], vec![])
203        .expect("engine config must contain a valid model id")
204}
205
206impl AxumServer {
207    /// Create a server with a fully populated AppState.
208    pub fn from_state(state: AppState) -> Self {
209        Self {
210            state,
211            config: ServerConfig::default(),
212            lifecycle: Arc::new(AxumServerLifecycle::default()),
213        }
214    }
215
216    /// Convenience constructor for an LLM-only server (chat / completions).
217    pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
218        Self::from_state(AppState::default().with_llm(engine))
219    }
220
221    /// Convenience constructor for an embedding-only server (`/v1/embeddings`).
222    pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
223        Self::from_state(AppState::default().with_embed(engine))
224    }
225
226    /// Convenience constructor for a transcription-only server
227    /// (`/v1/audio/transcriptions`).
228    pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
229        Self::from_state(AppState::default().with_transcribe(engine))
230    }
231
232    /// Convenience constructor for a TTS-only server (`/v1/audio/speech`).
233    pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
234        Self::from_state(AppState::default().with_tts(engine))
235    }
236
237    /// Attach the startup auto-configuration decision trace exposed by
238    /// `/health`. Constructors keep this optional so tests and non-LLM
239    /// deployments can use the server without a model-specific resolver.
240    pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
241        self.state = self.state.with_auto_config(auto_config);
242        self
243    }
244
245    /// Attach the loaded model's prompt template, if available. This keeps
246    /// OpenAI request aliases from driving prompt-family selection.
247    pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
248        self.state = self.state.with_prompt_template(prompt_template);
249        self
250    }
251
252    /// Set the server-wide reasoning default. Explicit standard effort or
253    /// `chat_template_kwargs.enable_thinking` overrides this default.
254    pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
255        self.state = self.state.with_default_enable_thinking(enable_thinking);
256        self
257    }
258
259    /// Control the compatibility retry for model templates that reject
260    /// non-leading system messages. It is enabled by default.
261    pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
262        self.state = self.state.with_interleaved_system_coalescing(enabled);
263        self
264    }
265
266    /// Install the public OpenAI model namespace used for request routing and
267    /// `/v1/models`. The registry keeps public aliases separate from the
268    /// engine's internal model id.
269    pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
270        self.state = self.state.with_served_model_registry(registry);
271        self
272    }
273
274    /// Attach startup-loaded LoRA adapter model ids.
275    pub fn with_lora_adapters(
276        mut self,
277        base_model_id: impl Into<String>,
278        adapters: Vec<LoraAdapterModel>,
279    ) -> ferrum_types::Result<Self> {
280        let base_model_id = base_model_id.into();
281        let registry = if self.state.served_model_registry.is_empty() {
282            ServedModelRegistry::try_new(
283                base_model_id.clone(),
284                ServedModelKind::Llm,
285                vec![base_model_id],
286                adapters,
287            )
288        } else {
289            self.state
290                .served_model_registry
291                .try_with_lora_adapters(&base_model_id, adapters)
292        }
293        .map_err(|error| Error::config(error.to_string()))?;
294        self.state = self.state.with_served_model_registry(registry);
295        Ok(self)
296    }
297
298    async fn shutdown_loaded_engines(&self) -> ferrum_types::Result<()> {
299        let mut first_error = None;
300        if let Some(engine) = &self.state.llm {
301            if let Err(error) = engine.shutdown().await {
302                first_error = Some(error);
303            }
304        }
305        if let Some(engine) = &self.state.embed {
306            if let Err(error) = engine.shutdown().await {
307                if first_error.is_none() {
308                    first_error = Some(error);
309                }
310            }
311        }
312        if let Some(engine) = &self.state.transcribe {
313            if let Err(error) = engine.shutdown().await {
314                if first_error.is_none() {
315                    first_error = Some(error);
316                }
317            }
318        }
319        if let Some(engine) = &self.state.tts {
320            if let Err(error) = engine.shutdown().await {
321                if first_error.is_none() {
322                    first_error = Some(error);
323                }
324            }
325        }
326        first_error.map_or(Ok(()), Err)
327    }
328
329    /// Build the router with all routes
330    #[allow(dead_code)]
331    fn build_router(&self) -> Router {
332        self.build_router_with_state(self.state.clone())
333    }
334
335    fn build_router_with_state(&self, app_state: AppState) -> Router {
336        Router::new()
337            // OpenAI API routes
338            .route("/v1/chat/completions", post(chat_completions_handler))
339            .route("/v1/responses", post(responses::responses_handler))
340            .route("/v1/completions", post(completions_handler))
341            .route("/v1/embeddings", post(embeddings_handler))
342            .route("/v1/audio/transcriptions", post(transcriptions_handler))
343            .route("/v1/audio/speech", post(speech_handler))
344            .route("/v1/models", get(models_handler))
345            // Health & observability
346            .route("/health", get(health_handler))
347            .route("/metrics", get(metrics_handler))
348            .route("/", get(root_handler))
349            // Apply middleware
350            .layer(
351                ServiceBuilder::new()
352                    .layer(TraceLayer::new_for_http())
353                    .layer(CorsLayer::permissive()), // For MVP, allow all origins
354            )
355            .with_state(app_state)
356    }
357}
358
359/// Application state shared across handlers — one optional engine per
360/// modality. Handlers reach into the field they need and 503 when it's
361/// not loaded.
362#[derive(Clone, Default)]
363pub struct AppState {
364    pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
365    pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
366    pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
367    pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
368    pub auto_config: Option<ResolvedFerrumConfig>,
369    pub prompt_template: Option<Arc<ModelChatTemplate>>,
370    pub default_enable_thinking: Option<bool>,
371    interleaved_system_coalescing: Option<bool>,
372    pub served_model_registry: Arc<ServedModelRegistry>,
373    pub request_dump_dir: Option<Arc<PathBuf>>,
374    pub profile_jsonl: Option<Arc<PathBuf>>,
375    pub profile_detail: ferrum_types::ObservabilityProfileDetail,
376    pub memory_profile_jsonl: Option<Arc<PathBuf>>,
377    pub first_request_memory_recorded: Arc<AtomicBool>,
378    cache: Arc<CacheRuntimeState>,
379}
380
381impl AppState {
382    pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
383        if self.served_model_registry.is_empty() {
384            self.served_model_registry = Arc::new(single_model_registry(
385                engine.config().model.model_id.clone(),
386                ServedModelKind::Llm,
387            ));
388        }
389        self.llm = Some(engine);
390        self
391    }
392    pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
393        if self.served_model_registry.is_empty() {
394            self.served_model_registry = Arc::new(single_model_registry(
395                engine.config().model.model_id.clone(),
396                ServedModelKind::Embedding,
397            ));
398        }
399        self.embed = Some(engine);
400        self
401    }
402    pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
403        if self.served_model_registry.is_empty() {
404            self.served_model_registry = Arc::new(single_model_registry(
405                engine.config().model.model_id.clone(),
406                ServedModelKind::Transcription,
407            ));
408        }
409        self.transcribe = Some(engine);
410        self
411    }
412    pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
413        if self.served_model_registry.is_empty() {
414            self.served_model_registry = Arc::new(single_model_registry(
415                engine.config().model.model_id.clone(),
416                ServedModelKind::Speech,
417            ));
418        }
419        self.tts = Some(engine);
420        self
421    }
422
423    pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
424        self.auto_config = Some(auto_config);
425        self
426    }
427
428    pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
429        self.prompt_template = prompt_template.map(Arc::new);
430        self
431    }
432
433    pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
434        self.default_enable_thinking = enable_thinking;
435        self
436    }
437
438    pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
439        self.interleaved_system_coalescing = Some(enabled);
440        self
441    }
442
443    pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
444        self.served_model_registry = Arc::new(registry);
445        self
446    }
447
448    pub fn with_request_dump_dir(mut self, request_dump_dir: Option<PathBuf>) -> Self {
449        self.request_dump_dir = request_dump_dir.map(Arc::new);
450        self
451    }
452
453    pub fn with_profile_jsonl(mut self, profile_jsonl: Option<PathBuf>) -> Self {
454        self.profile_jsonl = profile_jsonl.map(Arc::new);
455        self
456    }
457
458    pub fn with_profile_detail(
459        mut self,
460        profile_detail: ferrum_types::ObservabilityProfileDetail,
461    ) -> Self {
462        self.profile_detail = profile_detail;
463        self
464    }
465
466    pub fn with_memory_profile_jsonl(mut self, memory_profile_jsonl: Option<PathBuf>) -> Self {
467        self.memory_profile_jsonl = memory_profile_jsonl.map(Arc::new);
468        self
469    }
470
471    /// Async aggregated status across whichever modality is loaded.
472    /// In single-modality deployments (current CLI), exactly one is Some.
473    async fn status(&self) -> EngineStatus {
474        if let Some(e) = &self.llm {
475            return e.status().await;
476        }
477        if let Some(e) = &self.embed {
478            return e.status().await;
479        }
480        if let Some(e) = &self.transcribe {
481            return e.status().await;
482        }
483        if let Some(e) = &self.tts {
484            return e.status().await;
485        }
486        EngineStatus {
487            is_ready: false,
488            loaded_models: vec![],
489            active_requests: 0,
490            queued_requests: 0,
491            memory_usage: ferrum_types::MemoryUsage {
492                total_bytes: 0,
493                used_bytes: 0,
494                free_bytes: 0,
495                gpu_memory_bytes: None,
496                cpu_memory_bytes: None,
497                cache_memory_bytes: 0,
498                utilization_percent: 0.0,
499            },
500            uptime_seconds: 0,
501            last_heartbeat: chrono::Utc::now(),
502            version: env!("CARGO_PKG_VERSION").to_string(),
503        }
504    }
505
506    fn metrics(&self) -> EngineMetrics {
507        if let Some(e) = &self.llm {
508            return e.metrics();
509        }
510        if let Some(e) = &self.embed {
511            return e.metrics();
512        }
513        if let Some(e) = &self.transcribe {
514            return e.metrics();
515        }
516        if let Some(e) = &self.tts {
517            return e.metrics();
518        }
519        EngineMetrics {
520            total_requests: 0,
521            successful_requests: 0,
522            failed_requests: 0,
523            avg_request_latency_ms: 0.0,
524            p95_request_latency_ms: 0.0,
525            p99_request_latency_ms: 0.0,
526            throughput_rps: 0.0,
527            tokens_per_second: 0.0,
528            queue_metrics: Default::default(),
529            resource_utilization: Default::default(),
530            error_stats: Default::default(),
531            performance_breakdown: Default::default(),
532        }
533    }
534}
535
536#[derive(Default)]
537struct CacheRuntimeState {
538    stats: Mutex<CacheStats>,
539    prefix_prompts: Mutex<HashMap<String, usize>>,
540    sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
541}
542
543#[derive(Debug, Clone, Default)]
544struct CacheStats {
545    prefix_hits: u64,
546    prefix_misses: u64,
547    prefix_evictions: u64,
548    prefix_saved_prefill_tokens: u64,
549    prefix_entries: u64,
550    prefix_bytes: u64,
551    session_hits: u64,
552    session_misses: u64,
553    session_evictions: u64,
554    session_entries: u64,
555    session_tokens: u64,
556}
557
558#[derive(Clone)]
559struct SessionContext {
560    id: String,
561    prior_messages: Vec<ChatMessage>,
562    incoming_messages: Vec<ChatMessage>,
563}
564
565impl CacheRuntimeState {
566    fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
567        if !policy.prefix_cache_enabled {
568            return;
569        }
570
571        let prompt_tokens = approx_tokens(prompt);
572        let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
573        let saved_tokens = prompts
574            .keys()
575            .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
576            .max()
577            .unwrap_or(0);
578
579        let mut stats = self.stats.lock().expect("cache stats lock");
580        if saved_tokens > 0 {
581            stats.prefix_hits += 1;
582            stats.prefix_saved_prefill_tokens += saved_tokens as u64;
583        } else {
584            stats.prefix_misses += 1;
585        }
586
587        let max_entries = policy.session_cache_max_entries.max(1);
588        if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
589            if let Some(key) = prompts.keys().next().cloned() {
590                prompts.remove(&key);
591                stats.prefix_evictions += 1;
592            }
593        }
594        prompts.insert(prompt.to_string(), prompt_tokens);
595        stats.prefix_entries = prompts.len() as u64;
596        stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
597    }
598
599    fn prepare_session_request(
600        &self,
601        request: &mut ChatCompletionsRequest,
602        headers: &HeaderMap,
603        policy: &CachePolicy,
604    ) -> Option<SessionContext> {
605        let session_id = request_session_id(headers, request)?;
606        if !policy.session_memory_enabled() {
607            return None;
608        }
609
610        let incoming_messages = request.messages.clone();
611        let prior_messages = {
612            let sessions = self.sessions.lock().expect("session cache lock");
613            sessions.get(&session_id).cloned().unwrap_or_default()
614        };
615        {
616            let mut stats = self.stats.lock().expect("cache stats lock");
617            if prior_messages.is_empty() {
618                stats.session_misses += 1;
619            } else {
620                stats.session_hits += 1;
621                let mut merged = prior_messages.clone();
622                merged.extend(request.messages.clone());
623                request.messages = merged;
624            }
625        }
626
627        Some(SessionContext {
628            id: session_id,
629            prior_messages,
630            incoming_messages,
631        })
632    }
633
634    fn update_session(
635        &self,
636        context: Option<SessionContext>,
637        assistant_message: ChatMessage,
638        policy: &CachePolicy,
639    ) {
640        let Some(context) = context else {
641            return;
642        };
643        if !policy.session_memory_enabled() {
644            return;
645        }
646
647        let mut history = context.prior_messages;
648        history.extend(context.incoming_messages);
649        history.push(assistant_message);
650        trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
651
652        let mut sessions = self.sessions.lock().expect("session cache lock");
653        if !sessions.contains_key(&context.id)
654            && sessions.len() >= policy.session_cache_max_entries.max(1)
655        {
656            if let Some(evict_key) = sessions.keys().next().cloned() {
657                sessions.remove(&evict_key);
658                self.stats
659                    .lock()
660                    .expect("cache stats lock")
661                    .session_evictions += 1;
662            }
663        }
664        sessions.insert(context.id, history);
665
666        let entries = sessions.len() as u64;
667        let 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        let mut stats = self.stats.lock().expect("cache stats lock");
677        stats.session_entries = entries;
678        stats.session_tokens = tokens;
679    }
680
681    fn stats(&self) -> CacheStats {
682        let mut stats = self.stats.lock().expect("cache stats lock").clone();
683        stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
684        let sessions = self.sessions.lock().expect("session cache lock");
685        stats.session_entries = sessions.len() as u64;
686        stats.session_tokens = sessions
687            .values()
688            .map(|messages| {
689                messages
690                    .iter()
691                    .map(|msg| approx_tokens(&msg.content))
692                    .sum::<usize>()
693            })
694            .sum::<usize>() as u64;
695        stats
696    }
697
698    fn health_json(
699        &self,
700        policy: &CachePolicy,
701        engine_prefix_cache: Option<&serde_json::Value>,
702    ) -> serde_json::Value {
703        let stats = self.stats();
704        let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
705        let prefix_misses =
706            engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
707        let prefix_evictions =
708            engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
709        let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
710            .unwrap_or(stats.prefix_saved_prefill_tokens);
711        let prefix_entries =
712            engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
713        let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
714        let mut prefix_cache = serde_json::json!({
715            "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
716            "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
717            "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
718            "entries": prefix_entries,
719            "hits": prefix_hits,
720            "misses": prefix_misses,
721            "evictions": prefix_evictions,
722            "saved_prefill_tokens": prefix_saved,
723            "bytes": prefix_bytes,
724            "block_size": engine_u64(engine_prefix_cache, "block_size"),
725            "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
726        });
727        if let (Some(engine), Some(prefix)) = (
728            engine_prefix_cache.and_then(|value| value.as_object()),
729            prefix_cache.as_object_mut(),
730        ) {
731            for (key, value) in engine {
732                prefix.entry(key.clone()).or_insert_with(|| value.clone());
733            }
734        }
735        serde_json::json!({
736            "prefix_cache": prefix_cache,
737            "session_cache": {
738                "mode": policy.session_cache_mode,
739                "entries": stats.session_entries,
740                "hits": stats.session_hits,
741                "misses": stats.session_misses,
742                "evictions": stats.session_evictions,
743                "tokens": stats.session_tokens,
744                "max_entries": policy.session_cache_max_entries,
745                "max_tokens": policy.session_cache_max_tokens,
746            }
747        })
748    }
749
750    fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
751        let stats = self.stats();
752        let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
753        let prefix_misses =
754            engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
755        let prefix_evictions =
756            engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
757        let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
758            .unwrap_or(stats.prefix_saved_prefill_tokens);
759        let prefix_entries =
760            engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
761        let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
762        format!(
763            concat!(
764                "ferrum_prefix_cache_hits_total {}\n",
765                "ferrum_prefix_cache_misses_total {}\n",
766                "ferrum_prefix_cache_evictions_total {}\n",
767                "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
768                "ferrum_prefix_cache_entries {}\n",
769                "ferrum_prefix_cache_bytes {}\n",
770                "ferrum_session_cache_hits_total {}\n",
771                "ferrum_session_cache_misses_total {}\n",
772                "ferrum_session_cache_evictions_total {}\n",
773                "ferrum_session_cache_entries {}\n",
774                "ferrum_session_cache_tokens {}\n"
775            ),
776            prefix_hits,
777            prefix_misses,
778            prefix_evictions,
779            prefix_saved,
780            prefix_entries,
781            prefix_bytes,
782            stats.session_hits,
783            stats.session_misses,
784            stats.session_evictions,
785            stats.session_entries,
786            stats.session_tokens,
787        )
788    }
789}
790
791fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
792    snapshot?.get(key)?.as_u64()
793}
794
795fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
796    snapshot?.get(key)?.as_bool()
797}
798
799fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
800    snapshot?.get(key)?.as_str()
801}
802
803fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
804    match auto_config {
805        Some(auto_config) => auto_config.effective_config_document(),
806        None => {
807            match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
808                Ok(auto_config) => auto_config.effective_config_document(),
809                Err(err) => serde_json::json!({
810                    "schema_version": 1,
811                    "error": err.to_string(),
812                }),
813            }
814        }
815    }
816}
817
818fn admission_health_json(
819    engine_status: &EngineStatus,
820    scheduler_metrics: &EngineMetrics,
821    auto_config: &serde_json::Value,
822    runtime_snapshot: Option<&ferrum_types::ExecutorAdmissionSnapshot>,
823    runtime_error: Option<&str>,
824) -> serde_json::Value {
825    let configured = auto_config
826        .get("admission")
827        .and_then(|value| value.as_object());
828    let preflight_effective_max_concurrent = configured
829        .and_then(|value| value.get("effective_max_concurrent"))
830        .and_then(|value| value.as_u64());
831    let effective_max_concurrent = if runtime_error.is_some() {
832        None
833    } else {
834        Some(
835            runtime_snapshot
836                .map(|snapshot| u64::from(snapshot.maximum_active_sequences()))
837                .or(preflight_effective_max_concurrent)
838                .unwrap_or_else(|| {
839                    (engine_status.active_requests + engine_status.queued_requests)
840                        .max(1)
841                        .try_into()
842                        .unwrap_or(u64::MAX)
843                }),
844        )
845    };
846    let active_sequences = runtime_error.is_none().then(|| {
847        runtime_snapshot
848            .map(|snapshot| u64::from(snapshot.active_sequences()))
849            .unwrap_or_else(|| engine_status.active_requests as u64)
850    });
851    let waiting_requests = runtime_error.is_none().then(|| {
852        runtime_snapshot
853            .map(|snapshot| u64::from(snapshot.waiting_requests()))
854            .unwrap_or_else(|| engine_status.queued_requests as u64)
855    });
856    serde_json::json!({
857        "schema_version": 2,
858        "source": if runtime_error.is_some() {
859            "runtime_error"
860        } else if runtime_snapshot.is_some() {
861            "runtime_executor"
862        } else {
863            "startup_preflight_and_engine_status"
864        },
865        "runtime_snapshot_available": runtime_snapshot.is_some(),
866        "runtime_contract_error": runtime_error,
867        "resource_authority": runtime_snapshot
868            .and_then(|snapshot| serde_json::to_value(snapshot.resource_authority()).ok())
869            .unwrap_or(serde_json::Value::Null),
870        "effective_max_concurrent": effective_max_concurrent,
871        "maximum_active_sequences": runtime_snapshot
872            .map(|snapshot| u64::from(snapshot.maximum_active_sequences())),
873        "maximum_scheduled_tokens": runtime_snapshot
874            .map(|snapshot| snapshot.maximum_scheduled_tokens()),
875        "preflight_effective_max_concurrent": preflight_effective_max_concurrent,
876        "queue_depth": waiting_requests,
877        "active_sequences": active_sequences,
878        "active_prefill": runtime_snapshot
879            .map(|snapshot| u64::from(snapshot.active_prefill_sequences())),
880        "active_decode": runtime_snapshot
881            .map(|snapshot| u64::from(snapshot.active_decode_sequences())),
882        "current_batch_size": runtime_snapshot
883            .and_then(|snapshot| snapshot.current_batch_size())
884            .map(u64::from),
885        "capacity_blocked_requests": runtime_snapshot
886            .and_then(|snapshot| snapshot.capacity_blocked_requests())
887            .map(u64::from),
888        "rejected_requests_total": 0u64,
889        "failed_requests_total": scheduler_metrics.failed_requests,
890        "completed_requests_total": scheduler_metrics.successful_requests,
891        "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
892        "scheduler_policy": configured
893            .and_then(|value| value.get("scheduler_policy"))
894            .and_then(|value| value.as_str())
895            .unwrap_or("unknown"),
896        "phase_detail_source": if runtime_snapshot.is_some() {
897            "scheduler_request_index_single_read"
898        } else {
899            "unavailable"
900        },
901    })
902}
903
904fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
905    let snapshot_available = u8::from(
906        admission
907            .get("runtime_snapshot_available")
908            .and_then(serde_json::Value::as_bool)
909            .unwrap_or(false),
910    );
911    let mut output = format!("ferrum_admission_runtime_snapshot_available {snapshot_available}\n");
912    for (field, metric) in [
913        (
914            "effective_max_concurrent",
915            "ferrum_admission_effective_max_concurrent",
916        ),
917        (
918            "maximum_active_sequences",
919            "ferrum_admission_maximum_active_sequences",
920        ),
921        (
922            "maximum_scheduled_tokens",
923            "ferrum_admission_maximum_scheduled_tokens",
924        ),
925        ("queue_depth", "ferrum_admission_queue_depth"),
926        (
927            "capacity_blocked_requests",
928            "ferrum_admission_capacity_blocked_requests",
929        ),
930        ("active_sequences", "ferrum_admission_active_sequences"),
931        ("active_prefill", "ferrum_admission_active_prefill"),
932        ("active_decode", "ferrum_admission_active_decode"),
933        ("current_batch_size", "ferrum_admission_current_batch_size"),
934        (
935            "rejected_requests_total",
936            "ferrum_admission_rejected_requests_total",
937        ),
938        (
939            "failed_requests_total",
940            "ferrum_admission_failed_requests_total",
941        ),
942        (
943            "completed_requests_total",
944            "ferrum_admission_completed_requests_total",
945        ),
946    ] {
947        if let Some(value) = admission.get(field).and_then(serde_json::Value::as_u64) {
948            output.push_str(&format!("{metric} {value}\n"));
949        }
950    }
951    output
952}
953
954fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
955    headers
956        .get(FERRUM_SESSION_HEADER)
957        .and_then(|value| value.to_str().ok())
958        .map(str::trim)
959        .filter(|value| !value.is_empty())
960        .map(str::to_string)
961        .or_else(|| {
962            request
963                .metadata
964                .as_ref()
965                .and_then(|metadata| metadata.get("ferrum_session_id"))
966                .and_then(|value| value.as_str())
967                .map(str::trim)
968                .filter(|value| !value.is_empty())
969                .map(str::to_string)
970        })
971}
972
973fn benchmark_request_correlation(
974    headers: &HeaderMap,
975) -> std::result::Result<Option<BenchmarkRequestCorrelation>, ServerError> {
976    let header_value = |name: &'static str| {
977        headers
978            .get(name)
979            .map(|value| {
980                value.to_str().map_err(|_| {
981                    ServerError::invalid_request(
982                        format!("{name} must contain visible ASCII text"),
983                        Some(name),
984                    )
985                })
986            })
987            .transpose()
988    };
989    BenchmarkRequestCorrelation::from_header_values(
990        header_value(BENCHMARK_RUN_ID_HEADER)?,
991        header_value(BENCHMARK_CELL_ID_HEADER)?,
992        header_value(BENCHMARK_REPEAT_INDEX_HEADER)?,
993        header_value(BENCHMARK_PHASE_HEADER)?,
994        header_value(BENCHMARK_REQUEST_INDEX_HEADER)?,
995    )
996    .map_err(|error| ServerError::invalid_request(error, Some(BENCHMARK_RUN_ID_HEADER)))
997}
998
999fn extend_benchmark_profile_attributes(
1000    attributes: &mut BTreeMap<String, serde_json::Value>,
1001    correlation: Option<&BenchmarkRequestCorrelation>,
1002) {
1003    let Some(correlation) = correlation else {
1004        return;
1005    };
1006    attributes.extend([
1007        (
1008            "benchmark_run_id".to_string(),
1009            serde_json::json!(correlation.benchmark_run_id),
1010        ),
1011        (
1012            "cell_id".to_string(),
1013            serde_json::json!(correlation.cell_id),
1014        ),
1015        (
1016            "repeat_index".to_string(),
1017            serde_json::json!(correlation.repeat_index),
1018        ),
1019        (
1020            "phase".to_string(),
1021            serde_json::json!(correlation.phase.as_str()),
1022        ),
1023        (
1024            "request_index".to_string(),
1025            serde_json::json!(correlation.request_index),
1026        ),
1027    ]);
1028}
1029
1030fn approx_tokens(text: &str) -> usize {
1031    approx_tokens_for_chars(text.chars().count())
1032}
1033
1034fn approx_tokens_for_chars(chars: usize) -> usize {
1035    (chars / 4).max(1)
1036}
1037
1038fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
1039    a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
1040}
1041
1042fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
1043    let max_tokens = max_tokens.max(1);
1044    while messages.len() > 1
1045        && messages
1046            .iter()
1047            .map(|msg| approx_tokens(&msg.content))
1048            .sum::<usize>()
1049            > max_tokens
1050    {
1051        messages.remove(0);
1052    }
1053}
1054
1055#[async_trait]
1056impl HttpServer for AxumServer {
1057    async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
1058        if self.lifecycle.shutdown_requested.load(Ordering::Acquire) {
1059            return Err(Error::internal(
1060                "cannot start Axum server after shutdown was requested",
1061            ));
1062        }
1063        self.lifecycle
1064            .running
1065            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1066            .map_err(|_| Error::internal("Axum server is already running"))?;
1067        let _run_guard = AxumServerRunGuard {
1068            lifecycle: Arc::clone(&self.lifecycle),
1069        };
1070        let addr = format!("{}:{}", config.host, config.port);
1071        info!("Starting Axum server on {}", addr);
1072
1073        let app = self.build_router_with_state(
1074            self.state
1075                .clone()
1076                .with_request_dump_dir(config.request_dump_dir.clone())
1077                .with_profile_jsonl(config.profile_jsonl.clone())
1078                .with_profile_detail(config.profile_detail)
1079                .with_memory_profile_jsonl(config.memory_profile_jsonl.clone()),
1080        );
1081        let listener = tokio::net::TcpListener::bind(&addr)
1082            .await
1083            .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
1084
1085        info!("Server listening on {}", addr);
1086
1087        let lifecycle = Arc::clone(&self.lifecycle);
1088        axum::serve(listener, app)
1089            .with_graceful_shutdown(async move { lifecycle.wait_for_shutdown().await })
1090            .await
1091            .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
1092
1093        Ok(())
1094    }
1095
1096    async fn stop(&self, timeout: std::time::Duration) -> ferrum_types::Result<()> {
1097        let _stop_guard = self.lifecycle.stop_lock.lock().await;
1098        info!("Stopping Axum server");
1099        self.lifecycle.request_shutdown();
1100
1101        let mut first_error = None;
1102        if self.lifecycle.running.load(Ordering::Acquire) {
1103            if tokio::time::timeout(timeout, self.lifecycle.wait_until_stopped())
1104                .await
1105                .is_err()
1106            {
1107                first_error = Some(Error::internal(format!(
1108                    "Axum server did not drain within {} ms",
1109                    timeout.as_millis()
1110                )));
1111            }
1112        }
1113
1114        if !self.lifecycle.engines_stopped.load(Ordering::Acquire) {
1115            match tokio::time::timeout(timeout, self.shutdown_loaded_engines()).await {
1116                Ok(Ok(())) => {
1117                    self.lifecycle
1118                        .engines_stopped
1119                        .store(true, Ordering::Release);
1120                }
1121                Ok(Err(error)) => {
1122                    if first_error.is_none() {
1123                        first_error = Some(error);
1124                    }
1125                }
1126                Err(_) => {
1127                    if first_error.is_none() {
1128                        first_error = Some(Error::internal(format!(
1129                            "engine shutdown did not complete within {} ms",
1130                            timeout.as_millis()
1131                        )));
1132                    }
1133                }
1134            }
1135        }
1136
1137        first_error.map_or(Ok(()), Err)
1138    }
1139
1140    fn is_running(&self) -> bool {
1141        self.lifecycle.running.load(Ordering::Acquire)
1142    }
1143
1144    fn address(&self) -> Option<std::net::SocketAddr> {
1145        // For MVP, return configured address
1146        format!("{}:{}", self.config.host, self.config.port)
1147            .parse()
1148            .ok()
1149    }
1150
1151    fn register_handler(
1152        &mut self,
1153        _path: &str,
1154        _method: HttpMethod,
1155        _handler: Box<dyn crate::traits::RequestHandler>,
1156    ) {
1157        // For MVP, routes are static
1158        unimplemented!("Dynamic handler registration not implemented in MVP")
1159    }
1160
1161    fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
1162        // For MVP, middleware is static
1163        unimplemented!("Dynamic middleware registration not implemented in MVP")
1164    }
1165
1166    fn get_metrics(&self) -> ServerMetrics {
1167        // Return empty metrics for MVP
1168        ServerMetrics {
1169            total_requests: 0,
1170            requests_by_endpoint: std::collections::HashMap::new(),
1171            requests_by_status: std::collections::HashMap::new(),
1172            avg_response_time_ms: 0.0,
1173            p95_response_time_ms: 0.0,
1174            p99_response_time_ms: 0.0,
1175            active_connections: 0,
1176            bytes_sent: 0,
1177            bytes_received: 0,
1178            error_rate: 0.0,
1179            uptime_seconds: 0,
1180        }
1181    }
1182
1183    async fn health_check(&self) -> HealthStatus {
1184        HealthStatus::Healthy
1185    }
1186}
1187
1188/// Main chat completions handler
1189async fn chat_completions_handler(
1190    State(state): State<AppState>,
1191    headers: HeaderMap,
1192    request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1193) -> std::result::Result<Response, ServerError> {
1194    chat_completions_handler_with_phases(State(state), headers, request, None).await
1195}
1196
1197async fn chat_completions_handler_with_phases(
1198    State(state): State<AppState>,
1199    headers: HeaderMap,
1200    request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1201    mut message_phases: Option<Vec<Option<AssistantMessagePhase>>>,
1202) -> std::result::Result<Response, ServerError> {
1203    let Json(mut request) = request.map_err(|error| {
1204        ServerError::invalid_request(
1205            format!(
1206                "invalid chat completions request: {}",
1207                json_rejection_detail(&error)
1208            ),
1209            None,
1210        )
1211    })?;
1212    let benchmark_correlation = benchmark_request_correlation(&headers)?;
1213    let cache_policy = CachePolicy::current();
1214    if message_phases
1215        .as_ref()
1216        .is_some_and(|phases| phases.len() != request.messages.len())
1217    {
1218        return Err(ServerError::InternalError(
1219            "Responses message phase metadata did not match input history".to_string(),
1220        ));
1221    }
1222    let session_context =
1223        state
1224            .cache
1225            .prepare_session_request(&mut request, &headers, &cache_policy);
1226    if let Some(phases) = &mut message_phases {
1227        let prepended = request
1228            .messages
1229            .len()
1230            .checked_sub(phases.len())
1231            .ok_or_else(|| {
1232                ServerError::InternalError(
1233                    "session preparation shortened Responses input history".to_string(),
1234                )
1235            })?;
1236        phases.splice(0..0, std::iter::repeat(None).take(prepended));
1237    }
1238
1239    let span = span!(Level::INFO, "chat_completions", model = %request.model);
1240    let _enter = span.enter();
1241
1242    info!(
1243        "Received chat completions request for model: {}",
1244        request.model
1245    );
1246    debug!("Request: {:?}", request);
1247
1248    // OpenAI spec requires at least one message. Reject empty arrays at
1249    // the boundary rather than synthesising a fake prompt downstream.
1250    validate_chat_request(&request)?;
1251    let (engine_model_id, lora_adapter) = resolve_request_model(
1252        &state.served_model_registry,
1253        &request.model,
1254        ServedModelKind::Llm,
1255    )?;
1256
1257    // Convert OpenAI request to internal format
1258    let mut inference_request = convert_chat_request_with_template_model_and_default(
1259        &request,
1260        &engine_model_id.0,
1261        state.prompt_template.as_deref(),
1262        state.default_enable_thinking,
1263        state.interleaved_system_coalescing.unwrap_or(true),
1264        message_phases.as_deref(),
1265    )
1266    .map_err(server_error_from_ferrum_error)?;
1267    apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
1268    if state.request_dump_dir.is_some() {
1269        inference_request.evidence_request.capture_prompt_token_ids = true;
1270    }
1271    inference_request
1272        .evidence_request
1273        .capture_engine_token_timing = state.profile_detail.captures_engine_token_timing();
1274    state
1275        .cache
1276        .record_prefix_prompt(&inference_request.prompt, &cache_policy);
1277    if let Err(err) =
1278        write_chat_request_replay_bundle(&state, &headers, &request, &inference_request)
1279    {
1280        warn!("failed to write chat request replay bundle: {}", err);
1281    }
1282
1283    // Check if streaming is requested
1284    if request.stream.unwrap_or(false) {
1285        handle_chat_completions_stream(state, request, inference_request, benchmark_correlation)
1286            .await
1287    } else {
1288        handle_chat_completions_sync(
1289            state,
1290            request,
1291            inference_request,
1292            session_context,
1293            benchmark_correlation,
1294        )
1295        .await
1296    }
1297}
1298
1299fn json_rejection_detail(rejection: &JsonRejection) -> String {
1300    const MAX_DETAIL_CHARS: usize = 512;
1301
1302    let mut details = Vec::new();
1303    let mut current: Option<&(dyn StdError + 'static)> = Some(rejection);
1304    while let Some(error) = current {
1305        let detail = error.to_string();
1306        if !detail.is_empty() && details.last() != Some(&detail) {
1307            details.push(detail);
1308        }
1309        current = error.source();
1310    }
1311
1312    details.join(": ").chars().take(MAX_DETAIL_CHARS).collect()
1313}
1314
1315fn write_chat_request_replay_bundle(
1316    state: &AppState,
1317    headers: &HeaderMap,
1318    openai_request: &ChatCompletionsRequest,
1319    inference_request: &InferenceRequest,
1320) -> std::result::Result<(), String> {
1321    let Some(root) = state.request_dump_dir.as_ref() else {
1322        return Ok(());
1323    };
1324    let request_id = inference_request.id.to_string();
1325    let bundle_dir = root.join(&request_id);
1326    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1327
1328    let sanitized_body = sanitized_chat_request_body(openai_request);
1329    let replay_body_path = bundle_dir.join("replay_body.json");
1330    write_json_value(&replay_body_path, &sanitized_body)?;
1331    let engine_replay_argv = replay_bundle_argv(&bundle_dir);
1332    let output_text_body = format!(
1333        "[server request replay emitted before response]\nsha256={}\nchars=0\n",
1334        sha256_hex(b"")
1335    );
1336
1337    let request = serde_json::json!({
1338        "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1339        "entrypoint": "serve",
1340        "request_id": request_id,
1341        "model": openai_request.model.clone(),
1342        "backend": "actual",
1343        "endpoint": "/v1/chat/completions",
1344        "method": "POST",
1345        "stream": openai_request.stream.unwrap_or(false),
1346        "actual_model_smoke": true,
1347        "sanitized": true,
1348        "http": {
1349            "method": "POST",
1350            "path": "/v1/chat/completions",
1351            "headers": sanitized_replay_headers(headers),
1352            "body": sanitized_body
1353        }
1354    });
1355    let files = [
1356        ("request.json", request),
1357        (
1358            "prompt_token_ids.json",
1359            serde_json::json!({
1360                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1361                "request_id": request_id,
1362                "model": openai_request.model.clone(),
1363                "tokenizer_or_model": openai_request.model.clone(),
1364                "token_ids": null,
1365                "token_count": null,
1366                "unavailable_reason": "server request replay captures the OpenAI body before prompt token ids are retained",
1367                "sanitized": true
1368            }),
1369        ),
1370        (
1371            "sampling_params.json",
1372            serde_json::json!({
1373                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1374                "request_id": request_id,
1375                "sampling_params": inference_request.sampling_params.clone(),
1376                "unavailable_reason": null
1377            }),
1378        ),
1379        (
1380            "runtime_effective_config.json",
1381            serde_json::json!({
1382                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1383                "request_id": request_id,
1384                "entrypoint": "serve",
1385                "endpoint": "/v1/chat/completions",
1386                "stream": openai_request.stream.unwrap_or(false),
1387                "request_dump_dir": root.to_string_lossy(),
1388                "sanitized": true
1389            }),
1390        ),
1391        (
1392            "backend_selection.json",
1393            serde_json::json!({
1394                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1395                "request_id": request_id,
1396                "backend": "actual",
1397                "model": openai_request.model.clone(),
1398                "actual_model_smoke": true
1399            }),
1400        ),
1401        (
1402            "output_token_ids.json",
1403            serde_json::json!({
1404                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1405                "request_id": request_id,
1406                "token_ids": [],
1407                "token_count": 0,
1408                "finish_reason": null,
1409                "unavailable_reason": "server request replay bundle is emitted at request admission in this WP9 slice"
1410            }),
1411        ),
1412        (
1413            "bad_output_scan.json",
1414            serde_json::json!({
1415                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1416                "request_id": request_id,
1417                "bad_output": false,
1418                "bad_text_count": 0,
1419                "reasons": [],
1420                "first_bad_text_span": null,
1421                "failure_kind": null,
1422                "output_chars": 0,
1423                "classified_output_sha256": sha256_hex(b""),
1424                "output_sha256": sha256_hex(output_text_body.as_bytes())
1425            }),
1426        ),
1427        (
1428            "replay.command.json",
1429            serde_json::json!({
1430                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1431                "request_id": request_id,
1432                "entrypoint": "serve",
1433                "command": replay_curl_command(&bundle_dir),
1434                "argv": replay_curl_argv(&bundle_dir),
1435                "bundle_dir": bundle_dir.to_string_lossy(),
1436                "requires_running_server": true,
1437                "engine_replay": {
1438                    "mode": "bundle_offline",
1439                    "requires_http_server": false,
1440                    "command": shell_command(&engine_replay_argv),
1441                    "argv": engine_replay_argv
1442                },
1443                "sanitized": true
1444            }),
1445        ),
1446    ];
1447    for (name, value) in files {
1448        write_json_value(&bundle_dir.join(name), &value)?;
1449    }
1450    fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1451        .map_err(|err| err.to_string())?;
1452    Ok(())
1453}
1454
1455fn write_chat_request_failure_diagnostics(
1456    state: &AppState,
1457    request_id: &str,
1458    failure_kind: &str,
1459    phase: &str,
1460    error_kind: &str,
1461    message: &str,
1462    engine_status: Option<&EngineStatus>,
1463) -> std::result::Result<(), String> {
1464    let admission_summary = state
1465        .auto_config
1466        .as_ref()
1467        .map(|config| config.admission_summary_document());
1468    write_chat_request_failure_diagnostics_at_root(
1469        state.request_dump_dir.as_ref().map(|root| root.as_path()),
1470        admission_summary.as_ref(),
1471        engine_status,
1472        request_id,
1473        failure_kind,
1474        phase,
1475        error_kind,
1476        message,
1477    )
1478}
1479
1480fn write_chat_request_completion_replay_bundle(
1481    request_dump_dir: Option<&Path>,
1482    request_id: &str,
1483    output_text: &str,
1484    output_token_ids: &[TokenId],
1485    finish_reason: Option<&str>,
1486) -> std::result::Result<(), String> {
1487    let Some(root) = request_dump_dir else {
1488        return Ok(());
1489    };
1490    let bundle_dir = root.join(request_id);
1491    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1492    let token_ids = output_token_ids
1493        .iter()
1494        .map(|token| token.get())
1495        .collect::<Vec<_>>();
1496    let output_text_body = format!(
1497        "[redacted actual output]\nsha256={}\nchars={}\n",
1498        sha256_hex(output_text.as_bytes()),
1499        output_text.chars().count()
1500    );
1501    write_json_value(
1502        &bundle_dir.join("output_token_ids.json"),
1503        &serde_json::json!({
1504            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1505            "request_id": request_id,
1506            "token_ids": token_ids,
1507            "token_count": output_token_ids.len(),
1508            "finish_reason": finish_reason,
1509            "unavailable_reason": null
1510        }),
1511    )?;
1512    write_json_value(
1513        &bundle_dir.join("bad_output_scan.json"),
1514        &bad_output_scan_json(request_id, output_text, None, output_text_body.as_bytes()),
1515    )?;
1516    fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1517        .map_err(|err| err.to_string())?;
1518    Ok(())
1519}
1520
1521fn write_chat_prompt_token_evidence(
1522    request_dump_dir: Option<&Path>,
1523    request_id: &str,
1524    model: &str,
1525    execution_evidence: Option<&InferenceExecutionEvidence>,
1526) -> std::result::Result<(), String> {
1527    let (Some(root), Some(evidence)) = (request_dump_dir, execution_evidence) else {
1528        return Ok(());
1529    };
1530    let bundle_dir = root.join(request_id);
1531    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1532    let prompt_token_ids = evidence
1533        .prompt_token_ids
1534        .iter()
1535        .map(|token| token.get())
1536        .collect::<Vec<_>>();
1537    write_json_value(
1538        &bundle_dir.join("prompt_token_ids.json"),
1539        &serde_json::json!({
1540            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1541            "request_id": request_id,
1542            "model": model,
1543            "tokenizer_or_model": model,
1544            "token_ids": prompt_token_ids,
1545            "token_count": evidence.prompt_token_ids.len(),
1546            "unavailable_reason": null,
1547            "sanitized": true
1548        }),
1549    )
1550}
1551
1552#[derive(Clone, Copy, Default)]
1553struct ChatRequestProfileTiming<'a> {
1554    engine_evidence: Option<&'a InferenceExecutionEvidence>,
1555    first_engine_chunk_received_us: Option<u64>,
1556    first_sse_enqueue_us: Option<u64>,
1557}
1558
1559#[allow(clippy::too_many_arguments)]
1560fn write_chat_request_profile_event(
1561    state: &AppState,
1562    request_id: &str,
1563    benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1564    model: &str,
1565    stream: bool,
1566    phase: &str,
1567    started_at: Instant,
1568    timing: ChatRequestProfileTiming<'_>,
1569    output_token_count: usize,
1570    usage: Option<&TokenUsage>,
1571    finish_reason: Option<&str>,
1572    error: Option<ProfileError>,
1573) -> std::result::Result<(), String> {
1574    let Some(path) = state.profile_jsonl.as_ref() else {
1575        return Ok(());
1576    };
1577    let timestamp = chrono::Utc::now();
1578    let status = if error.is_some() {
1579        ProfileStatus::Failure
1580    } else {
1581        ProfileStatus::Ok
1582    };
1583    let duration_us = elapsed_us_since(started_at);
1584    let mut attributes = BTreeMap::from([
1585        ("actual_model_smoke".to_string(), serde_json::json!(true)),
1586        (
1587            "diagnostic_only".to_string(),
1588            serde_json::json!(state.profile_detail.diagnostic_only()),
1589        ),
1590        (
1591            "endpoint".to_string(),
1592            serde_json::json!("/v1/chat/completions"),
1593        ),
1594        (
1595            "e2e_duration_us".to_string(),
1596            serde_json::json!(duration_us),
1597        ),
1598        ("l0_only".to_string(), serde_json::json!(false)),
1599        (
1600            "profile_detail".to_string(),
1601            serde_json::json!(state.profile_detail.as_str()),
1602        ),
1603        ("stream".to_string(), serde_json::json!(stream)),
1604        (
1605            "output_token_count".to_string(),
1606            serde_json::json!(output_token_count),
1607        ),
1608        (
1609            "execution_request_id".to_string(),
1610            serde_json::json!(format!("request.product.{request_id}")),
1611        ),
1612    ]);
1613    extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1614    if let Some(usage) = usage {
1615        attributes.insert(
1616            "prompt_token_count".to_string(),
1617            serde_json::json!(usage.prompt_tokens),
1618        );
1619        attributes.insert(
1620            "completion_token_count".to_string(),
1621            serde_json::json!(usage.completion_tokens),
1622        );
1623        attributes.insert(
1624            "total_token_count".to_string(),
1625            serde_json::json!(usage.total_tokens),
1626        );
1627        attributes.insert("token_count_source".to_string(), serde_json::json!("usage"));
1628    } else {
1629        attributes.insert(
1630            "completion_token_count".to_string(),
1631            serde_json::json!(output_token_count),
1632        );
1633        attributes.insert(
1634            "total_token_count".to_string(),
1635            serde_json::json!(output_token_count),
1636        );
1637        attributes.insert(
1638            "token_count_source".to_string(),
1639            serde_json::json!("generated_tokens"),
1640        );
1641    }
1642    if let Some(engine_timing) = timing
1643        .engine_evidence
1644        .and_then(|evidence| evidence.engine_token_timing.as_ref())
1645    {
1646        engine_timing
1647            .validate(output_token_count)
1648            .map_err(|error| format!("invalid engine token timing evidence: {error}"))?;
1649        attributes.extend(ferrum_types::engine_token_timing_profile_attributes(
1650            engine_timing,
1651        ));
1652    } else if status == ProfileStatus::Ok && state.profile_detail.captures_engine_token_timing() {
1653        return Err(format!(
1654            "{} profile completed without required engine token timing evidence",
1655            state.profile_detail.as_str()
1656        ));
1657    }
1658    if let Some(received_us) = timing.first_engine_chunk_received_us {
1659        attributes.insert(
1660            "engine_stream_first_chunk_received_us".to_string(),
1661            serde_json::json!(received_us),
1662        );
1663    }
1664    if let Some(enqueue_us) = timing.first_sse_enqueue_us {
1665        attributes.insert(
1666            "http_first_sse_enqueue_us".to_string(),
1667            serde_json::json!(enqueue_us),
1668        );
1669    }
1670    if stream {
1671        attributes.insert(
1672            "http_stream_flush_unavailable_reason".to_string(),
1673            serde_json::json!(
1674                "socket flush completion is outside the axum handler observation boundary"
1675            ),
1676        );
1677    }
1678    if let Some(reason) = finish_reason {
1679        attributes.insert("finish_reason".to_string(), serde_json::json!(reason));
1680    }
1681    if let Some(error) = error.as_ref() {
1682        attributes.insert(
1683            if error.blocking {
1684                "first_failure_event"
1685            } else {
1686                "terminal_failure_event"
1687            }
1688            .to_string(),
1689            serde_json::json!(true),
1690        );
1691    }
1692
1693    let replay = state.request_dump_dir.as_ref().map(|root| {
1694        let bundle_dir = root.join(request_id);
1695        ReplayReference {
1696            command: replay_curl_command(&bundle_dir),
1697            bundle_dir: Some(root.to_string_lossy().to_string()),
1698        }
1699    });
1700    let resource = error.as_ref().map(|error| ResourceTraceEvent {
1701        owner_kind: "request".to_string(),
1702        owner_id: request_id.to_string(),
1703        resource_kind: "chat_request".to_string(),
1704        action: ResourceAction::Reject,
1705        amount: None,
1706        before: None,
1707        after: None,
1708        capacity: Some(1),
1709        underflow_amount: None,
1710        reason: Some(error.message.clone()),
1711        error_kind: Some(error.kind.clone()),
1712        message: Some(error.message.clone()),
1713        resource_error_kind: Some(error.kind.clone()),
1714    });
1715    let event = FerrumProfileEvent {
1716        schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1717        ts_unix_nanos: timestamp
1718            .timestamp_nanos_opt()
1719            .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1720        event_id: format!(
1721            "evt-server-chat-{}-{request_id}",
1722            if stream { "stream" } else { "sync" }
1723        ),
1724        request_id: request_id.to_string(),
1725        correlation_id: Some(request_id.to_string()),
1726        entrypoint: ProfileEntrypoint::Serve,
1727        backend: "actual".to_string(),
1728        runtime_preset_hash: state
1729            .auto_config
1730            .as_ref()
1731            .map(ResolvedFerrumConfig::runtime_env_hash)
1732            .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1733        phase: phase.to_string(),
1734        event_kind: ProfileEventKind::TimedSpan,
1735        timestamp,
1736        status,
1737        model: Some(model.to_string()),
1738        duration_us: Some(duration_us),
1739        memory: None,
1740        resource,
1741        error,
1742        replay,
1743        shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1744        backend_detail: None,
1745        attributes,
1746    };
1747    append_profile_event(path.as_path(), &event)
1748}
1749
1750fn maybe_write_first_request_memory_stage(
1751    state: &AppState,
1752    request_id: &str,
1753    benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1754    model: &str,
1755    stream: bool,
1756    started_at: Instant,
1757    before: Option<ProcessMemorySample>,
1758) -> std::result::Result<(), String> {
1759    if state.profile_jsonl.is_none() && state.memory_profile_jsonl.is_none() {
1760        return Ok(());
1761    }
1762    if state
1763        .first_request_memory_recorded
1764        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1765        .is_err()
1766    {
1767        return Ok(());
1768    }
1769    let after = ProcessMemorySampler.sample();
1770    let memory = after.map(|after| ProcessMemoryObservation::from_samples(before, after));
1771    let timestamp = chrono::Utc::now();
1772    let mut attributes = BTreeMap::from([
1773        ("actual_model_smoke".to_string(), serde_json::json!(true)),
1774        (
1775            "diagnostic_only".to_string(),
1776            serde_json::json!(state.profile_detail.diagnostic_only()),
1777        ),
1778        (
1779            "endpoint".to_string(),
1780            serde_json::json!("/v1/chat/completions"),
1781        ),
1782        ("l0_only".to_string(), serde_json::json!(false)),
1783        (
1784            "memory_stage".to_string(),
1785            serde_json::json!("first_request_done"),
1786        ),
1787        (
1788            "profile_detail".to_string(),
1789            serde_json::json!(state.profile_detail.as_str()),
1790        ),
1791        ("stream".to_string(), serde_json::json!(stream)),
1792    ]);
1793    extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1794    let memory_snapshot = if let Some(memory) = &memory {
1795        attributes.insert(
1796            "memory_measurement".to_string(),
1797            serde_json::json!("process_rss"),
1798        );
1799        attributes.insert(
1800            "process_memory_source".to_string(),
1801            serde_json::json!(memory.source),
1802        );
1803        memory.to_snapshot("process", Some("actual"))
1804    } else {
1805        attributes.insert(
1806            "memory_measurement".to_string(),
1807            serde_json::json!("not_collected"),
1808        );
1809        ferrum_types::MemorySnapshot {
1810            scope: "process".to_string(),
1811            backend: Some("actual".to_string()),
1812            before_bytes: Some(0),
1813            after_bytes: Some(0),
1814            current_bytes: Some(0),
1815            high_water_bytes: Some(0),
1816            available_bytes: None,
1817        }
1818    };
1819    let replay = state.request_dump_dir.as_ref().map(|root| {
1820        let bundle_dir = root.join(request_id);
1821        ReplayReference {
1822            command: replay_curl_command(&bundle_dir),
1823            bundle_dir: Some(root.to_string_lossy().to_string()),
1824        }
1825    });
1826    let event = FerrumProfileEvent {
1827        schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1828        ts_unix_nanos: timestamp
1829            .timestamp_nanos_opt()
1830            .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1831        event_id: format!("evt-server-chat-memory-first-request-{request_id}"),
1832        request_id: request_id.to_string(),
1833        correlation_id: Some(request_id.to_string()),
1834        entrypoint: ProfileEntrypoint::Serve,
1835        backend: "actual".to_string(),
1836        runtime_preset_hash: state
1837            .auto_config
1838            .as_ref()
1839            .map(ResolvedFerrumConfig::runtime_env_hash)
1840            .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1841        phase: "actual_serve_first_request_done".to_string(),
1842        event_kind: ProfileEventKind::Memory,
1843        timestamp,
1844        status: ProfileStatus::Ok,
1845        model: Some(model.to_string()),
1846        duration_us: Some(elapsed_us_since(started_at)),
1847        memory: Some(memory_snapshot),
1848        resource: None,
1849        error: None,
1850        replay,
1851        shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1852        backend_detail: None,
1853        attributes,
1854    };
1855    if let Some(path) = &state.profile_jsonl {
1856        append_profile_event(path.as_path(), &event)?;
1857    }
1858    if let Some(path) = &state.memory_profile_jsonl {
1859        append_profile_event(path.as_path(), &event)?;
1860    }
1861    Ok(())
1862}
1863
1864fn request_memory_sample_before(state: &AppState) -> Option<ProcessMemorySample> {
1865    (state.profile_jsonl.is_some() || state.memory_profile_jsonl.is_some())
1866        .then(|| ProcessMemorySampler.sample())
1867        .flatten()
1868}
1869
1870fn append_profile_event(
1871    path: &Path,
1872    event: &FerrumProfileEvent,
1873) -> std::result::Result<(), String> {
1874    event.validate().map_err(|err| err.to_string())?;
1875    ferrum_bench_core::write_jsonl_records(
1876        path,
1877        ferrum_bench_core::JsonlJournalOpenMode::Append,
1878        std::slice::from_ref(event),
1879    )
1880    .map_err(|error| error.to_string())
1881}
1882
1883fn elapsed_us_since(started_at: Instant) -> u64 {
1884    started_at
1885        .elapsed()
1886        .as_micros()
1887        .max(1)
1888        .try_into()
1889        .unwrap_or(u64::MAX)
1890}
1891
1892fn write_chat_request_failure_diagnostics_at_root(
1893    request_dump_dir: Option<&Path>,
1894    admission_summary: Option<&serde_json::Value>,
1895    engine_status: Option<&EngineStatus>,
1896    request_id: &str,
1897    failure_kind: &str,
1898    phase: &str,
1899    error_kind: &str,
1900    message: &str,
1901) -> std::result::Result<(), String> {
1902    let Some(root) = request_dump_dir else {
1903        return Ok(());
1904    };
1905    let bundle_dir = root.join(request_id);
1906    fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1907    let message = sanitize_diagnostic_text(message);
1908    let now = chrono::Utc::now();
1909
1910    let bad_scan_path = bundle_dir.join("bad_output_scan.json");
1911    let mut bad_scan = fs::read_to_string(&bad_scan_path)
1912        .ok()
1913        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
1914        .filter(|value| value.is_object())
1915        .unwrap_or_else(|| serde_json::json!({}));
1916    let bad_scan_obj = bad_scan
1917        .as_object_mut()
1918        .expect("bad scan fallback should be an object");
1919    bad_scan_obj.insert(
1920        "schema_version".to_string(),
1921        serde_json::json!(OBSERVABILITY_PROFILE_SCHEMA_VERSION),
1922    );
1923    bad_scan_obj.insert("request_id".to_string(), serde_json::json!(request_id));
1924    bad_scan_obj
1925        .entry("bad_output".to_string())
1926        .or_insert_with(|| serde_json::json!(false));
1927    bad_scan_obj
1928        .entry("bad_text_count".to_string())
1929        .or_insert_with(|| serde_json::json!(0));
1930    bad_scan_obj
1931        .entry("reasons".to_string())
1932        .or_insert_with(|| serde_json::json!([]));
1933    bad_scan_obj
1934        .entry("first_bad_text_span".to_string())
1935        .or_insert(serde_json::Value::Null);
1936    bad_scan_obj.insert("failure_kind".to_string(), serde_json::json!(failure_kind));
1937    bad_scan_obj.insert("failure_phase".to_string(), serde_json::json!(phase));
1938    bad_scan_obj.insert("error_kind".to_string(), serde_json::json!(error_kind));
1939    bad_scan_obj
1940        .entry("output_chars".to_string())
1941        .or_insert_with(|| serde_json::json!(0));
1942    bad_scan_obj
1943        .entry("output_sha256".to_string())
1944        .or_insert_with(|| serde_json::json!(sha256_hex(b"")));
1945    write_json_value(&bad_scan_path, &bad_scan)?;
1946
1947    let diagnostics = if chat_resource_failure_kind(failure_kind) {
1948        chat_resource_failure_diagnostics(
1949            request_id,
1950            failure_kind,
1951            phase,
1952            error_kind,
1953            &message,
1954            now.timestamp_millis(),
1955            admission_summary,
1956            engine_status,
1957        )
1958    } else {
1959        serde_json::json!({
1960            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1961            "entrypoint": "serve",
1962            "request_id": request_id,
1963            "failure_kind": failure_kind,
1964            "phase": phase,
1965            "first_failure_event": {
1966                "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1967                "entrypoint": "serve",
1968                "request_id": request_id,
1969                "phase": phase,
1970                "error_kind": error_kind,
1971                "message": message,
1972                "timestamp_unix_ms": now.timestamp_millis()
1973            },
1974            "nearest_request_id": request_id,
1975            "log_excerpt": format!("{phase}: {message}"),
1976            "backtrace_excerpt": null,
1977            "nearest_resource_event": null,
1978            "nearest_memory_snapshot": null
1979        })
1980    };
1981    write_json_value(&bundle_dir.join("failure_diagnostics.json"), &diagnostics)?;
1982    Ok(())
1983}
1984
1985fn chat_resource_failure_diagnostics(
1986    request_id: &str,
1987    failure_kind: &str,
1988    phase: &str,
1989    error_kind: &str,
1990    message: &str,
1991    timestamp_unix_ms: i64,
1992    admission_summary: Option<&serde_json::Value>,
1993    engine_status: Option<&EngineStatus>,
1994) -> serde_json::Value {
1995    let resource_kind = chat_resource_kind_for_failure(failure_kind);
1996    let memory = engine_status
1997        .map(|status| &status.memory_usage)
1998        .map(|memory| {
1999            let current = memory.used_bytes as i64;
2000            let high_water = current.max(0);
2001            serde_json::json!({
2002                "scope": "serve_failure",
2003                "backend": "engine_status",
2004                "current_bytes": current.max(0),
2005                "high_water_bytes": high_water,
2006                "total_bytes": memory.total_bytes,
2007                "free_bytes": memory.free_bytes,
2008                "gpu_memory_bytes": memory.gpu_memory_bytes,
2009                "cpu_memory_bytes": memory.cpu_memory_bytes,
2010                "source": "engine_status"
2011            })
2012        })
2013        .unwrap_or_else(|| {
2014            serde_json::json!({
2015                "scope": "serve_failure",
2016                "backend": "engine_status",
2017                "current_bytes": 0,
2018                "high_water_bytes": 0,
2019                "source": "not_collected"
2020            })
2021        });
2022    let capacity = chat_failure_capacity(resource_kind, admission_summary, engine_status, message);
2023    let needed = capacity
2024        .get("needed")
2025        .and_then(|value| value.as_i64())
2026        .unwrap_or(1)
2027        .max(1);
2028    let capacity_value = capacity
2029        .get("capacity")
2030        .and_then(|value| value.as_i64())
2031        .unwrap_or(0)
2032        .max(0);
2033    serde_json::json!({
2034        "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2035        "entrypoint": "serve",
2036        "request_id": request_id,
2037        "failure_kind": failure_kind,
2038        "phase": phase,
2039        "first_failure_event": {
2040            "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2041            "entrypoint": "serve",
2042            "request_id": request_id,
2043            "phase": phase,
2044            "error_kind": error_kind,
2045            "message": message,
2046            "timestamp_unix_ms": timestamp_unix_ms
2047        },
2048        "nearest_request_id": request_id,
2049        "log_excerpt": format!("{phase}: {message}"),
2050        "capacity": capacity,
2051        "nearest_resource_event": {
2052            "owner_kind": "request",
2053            "owner_id": request_id,
2054            "resource_kind": resource_kind,
2055            "action": "reject",
2056            "amount": needed,
2057            "before": capacity_value,
2058            "after": capacity_value,
2059            "capacity": capacity_value,
2060            "reason": message
2061        },
2062        "nearest_memory_snapshot": memory
2063    })
2064}
2065
2066fn chat_failure_capacity(
2067    resource_kind: &str,
2068    admission_summary: Option<&serde_json::Value>,
2069    engine_status: Option<&EngineStatus>,
2070    reason: &str,
2071) -> serde_json::Value {
2072    if resource_kind == "device_memory" {
2073        let (needed, available, capacity) = engine_status
2074            .map(|status| {
2075                let memory = &status.memory_usage;
2076                let used = memory.used_bytes as i64;
2077                let available = memory.free_bytes as i64;
2078                let capacity = memory.total_bytes as i64;
2079                (
2080                    used.saturating_add(1).max(1),
2081                    available.max(0),
2082                    capacity.max(0),
2083                )
2084            })
2085            .unwrap_or((1, 0, 0));
2086        return serde_json::json!({
2087            "resource_kind": resource_kind,
2088            "needed": needed,
2089            "available": available,
2090            "capacity": capacity,
2091            "reason": reason
2092        });
2093    }
2094    let capacity = admission_summary
2095        .and_then(|summary| summary.get("effective_max_concurrent"))
2096        .and_then(|value| {
2097            value
2098                .as_i64()
2099                .or_else(|| value.as_u64().map(|value| value as i64))
2100        })
2101        .unwrap_or_else(|| {
2102            engine_status
2103                .map(|status| {
2104                    (status.active_requests as i64)
2105                        .saturating_add(status.queued_requests as i64)
2106                        .saturating_add(1)
2107                })
2108                .unwrap_or(0)
2109        })
2110        .max(0);
2111    let used = engine_status
2112        .map(|status| (status.active_requests as i64).saturating_add(status.queued_requests as i64))
2113        .unwrap_or(0)
2114        .max(0);
2115    serde_json::json!({
2116        "resource_kind": resource_kind,
2117        "needed": 1,
2118        "available": capacity.saturating_sub(used),
2119        "capacity": capacity,
2120        "reason": reason
2121    })
2122}
2123
2124fn chat_resource_failure_kind(failure_kind: &str) -> bool {
2125    matches!(
2126        failure_kind,
2127        "oom" | "prevented_oom" | "admission" | "admission_reject" | "oom_admission"
2128    )
2129}
2130
2131fn chat_resource_kind_for_failure(failure_kind: &str) -> &'static str {
2132    match failure_kind {
2133        "oom" | "prevented_oom" => "device_memory",
2134        _ => "admission_capacity",
2135    }
2136}
2137
2138fn sanitize_diagnostic_text(message: &str) -> String {
2139    let trimmed = message.trim();
2140    if trimmed.is_empty() {
2141        return "generation failed without an error message".to_string();
2142    }
2143    let lower = trimmed.to_ascii_lowercase();
2144    if lower.contains("authorization")
2145        || lower.contains("cookie")
2146        || lower.contains("api_key")
2147        || lower.contains("access_token")
2148        || lower.contains("refresh_token")
2149        || lower.contains("password")
2150        || trimmed.contains("sk-")
2151    {
2152        return "[redacted diagnostic message]".to_string();
2153    }
2154    trimmed.chars().take(2048).collect()
2155}
2156
2157fn sanitized_replay_headers(headers: &HeaderMap) -> serde_json::Value {
2158    let mut result = serde_json::Map::new();
2159    for key in ["content-type", "traceparent", "tracestate"] {
2160        if let Some(value) = headers.get(key).and_then(|value| value.to_str().ok()) {
2161            result.insert(key.to_string(), serde_json::json!(value));
2162        }
2163    }
2164    result.insert("authorization".to_string(), serde_json::json!("[redacted]"));
2165    result.insert("cookie".to_string(), serde_json::json!("[redacted]"));
2166    serde_json::Value::Object(result)
2167}
2168
2169fn sanitized_chat_request_body(request: &ChatCompletionsRequest) -> serde_json::Value {
2170    let mut value = serde_json::to_value(request).unwrap_or_else(|_| {
2171        serde_json::json!({
2172            "model": request.model.clone(),
2173            "stream": request.stream.unwrap_or(false),
2174            "messages": []
2175        })
2176    });
2177    redact_json_value(&mut value, None);
2178    value
2179}
2180
2181fn redact_json_value(value: &mut serde_json::Value, key: Option<&str>) {
2182    if key.is_some_and(is_secret_key) {
2183        *value = serde_json::json!("[redacted]");
2184        return;
2185    }
2186    if matches!(key, Some("content" | "arguments")) && value.is_string() {
2187        *value = serde_json::json!("[redacted]");
2188        return;
2189    }
2190    match value {
2191        serde_json::Value::Object(map) => {
2192            for field in ["content", "arguments"] {
2193                if let Some(chars) = map
2194                    .get(field)
2195                    .and_then(|child| child.as_str())
2196                    .map(|text| text.chars().count())
2197                {
2198                    map.insert(field.to_string(), serde_json::json!("[redacted]"));
2199                    map.insert(format!("{field}_redacted"), serde_json::json!(true));
2200                    map.insert(format!("{field}_chars"), serde_json::json!(chars));
2201                }
2202            }
2203            for (child_key, child) in map.iter_mut() {
2204                redact_json_value(child, Some(child_key.as_str()));
2205            }
2206        }
2207        serde_json::Value::Array(items) => {
2208            for child in items {
2209                redact_json_value(child, None);
2210            }
2211        }
2212        _ => {}
2213    }
2214}
2215
2216fn is_secret_key(key: &str) -> bool {
2217    let normalized = key
2218        .chars()
2219        .filter(|ch| *ch != '-' && *ch != '_')
2220        .flat_map(char::to_lowercase)
2221        .collect::<String>();
2222    matches!(
2223        normalized.as_str(),
2224        "authorization"
2225            | "cookie"
2226            | "secret"
2227            | "apikey"
2228            | "password"
2229            | "accesstoken"
2230            | "refreshtoken"
2231            | "idtoken"
2232    )
2233}
2234
2235fn replay_curl_argv(bundle_dir: &Path) -> Vec<String> {
2236    vec![
2237        "curl".to_string(),
2238        "-sS".to_string(),
2239        "-X".to_string(),
2240        "POST".to_string(),
2241        "http://127.0.0.1:8000/v1/chat/completions".to_string(),
2242        "-H".to_string(),
2243        "content-type: application/json".to_string(),
2244        "--data-binary".to_string(),
2245        format!("@{}", bundle_dir.join("replay_body.json").display()),
2246    ]
2247}
2248
2249fn replay_curl_command(bundle_dir: &Path) -> String {
2250    shell_command(&replay_curl_argv(bundle_dir))
2251}
2252
2253fn replay_bundle_argv(bundle_dir: &Path) -> Vec<String> {
2254    vec![
2255        "cargo".to_string(),
2256        "run".to_string(),
2257        "-p".to_string(),
2258        "ferrum-cli".to_string(),
2259        "--".to_string(),
2260        "replay-bundle".to_string(),
2261        bundle_dir.to_string_lossy().to_string(),
2262        "--out".to_string(),
2263        bundle_dir
2264            .join("engine_replay")
2265            .to_string_lossy()
2266            .to_string(),
2267        "--json".to_string(),
2268    ]
2269}
2270
2271fn shell_command(argv: &[String]) -> String {
2272    argv.iter()
2273        .map(|part| shell_quote(part))
2274        .collect::<Vec<_>>()
2275        .join(" ")
2276}
2277
2278fn shell_quote(value: &str) -> String {
2279    if value
2280        .chars()
2281        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '@'))
2282    {
2283        value.to_string()
2284    } else {
2285        format!("'{}'", value.replace('\'', "'\\''"))
2286    }
2287}
2288
2289fn write_json_value(path: &Path, value: &serde_json::Value) -> std::result::Result<(), String> {
2290    let bytes = serde_json::to_vec_pretty(value).map_err(|err| err.to_string())?;
2291    fs::write(path, [bytes, b"\n".to_vec()].concat()).map_err(|err| err.to_string())
2292}
2293
2294fn bad_output_scan_json(
2295    request_id: &str,
2296    text: &str,
2297    failure_kind: Option<&str>,
2298    output_artifact_bytes: &[u8],
2299) -> serde_json::Value {
2300    let mut reasons = Vec::new();
2301    let mut first_span: Option<serde_json::Value> = None;
2302    for (needle, reason) in [
2303        ("<unk>", "reserved_token"),
2304        ("[PAD", "reserved_token"),
2305        ("<pad>", "reserved_token"),
2306        ("<|endoftext|>", "reserved_token"),
2307        ("<|im_start|>", "reserved_token"),
2308        ("<|im_end|>", "reserved_token"),
2309        ("<|reserved_special_token", "reserved_token"),
2310        ("\u{fffd}", "invalid_utf8"),
2311    ] {
2312        if let Some(index) = text.find(needle) {
2313            reasons.push(reason);
2314            first_span.get_or_insert_with(|| {
2315                serde_json::json!({
2316                    "byte_start": index,
2317                    "byte_end": index + needle.len(),
2318                    "text": needle,
2319                    "reason": reason
2320                })
2321            });
2322        }
2323    }
2324    if let Some(index) = first_mojibake_index(text) {
2325        reasons.push("mojibake");
2326        first_span.get_or_insert_with(|| {
2327            serde_json::json!({
2328                "byte_start": index,
2329                "byte_end": index + 1,
2330                "reason": "mojibake"
2331            })
2332        });
2333    }
2334    reasons.sort_unstable();
2335    reasons.dedup();
2336    let bad_output = !reasons.is_empty();
2337    serde_json::json!({
2338        "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2339        "request_id": request_id,
2340        "bad_output": bad_output,
2341        "bad_text_count": if bad_output { 1 } else { 0 },
2342        "reasons": reasons,
2343        "first_bad_text_span": first_span,
2344        "failure_kind": failure_kind,
2345        "output_chars": text.chars().count(),
2346        "classified_output_sha256": sha256_hex(text.as_bytes()),
2347        "output_sha256": sha256_hex(output_artifact_bytes)
2348    })
2349}
2350
2351fn first_mojibake_index(text: &str) -> Option<usize> {
2352    let mut chars = text.char_indices().peekable();
2353    while let Some((index, ch)) = chars.next() {
2354        match ch {
2355            '\u{00c2}' | '\u{00c3}' => {
2356                if chars.peek().is_some_and(|(_, next)| !next.is_ascii()) {
2357                    return Some(index);
2358                }
2359            }
2360            '\u{00e2}' => {
2361                if chars.peek().is_some_and(|(_, next)| *next == '\u{20ac}') {
2362                    return Some(index);
2363                }
2364            }
2365            _ => {}
2366        }
2367    }
2368    None
2369}
2370
2371fn sha256_hex(bytes: &[u8]) -> String {
2372    let mut hasher = Sha256::new();
2373    hasher.update(bytes);
2374    format!("{:x}", hasher.finalize())
2375}
2376
2377struct ParsedChatModelOutput {
2378    visible: ParsedReasoningResponse,
2379    harmony_response: Option<ferrum_types::ApiChatResponse>,
2380}
2381
2382fn parse_chat_model_output(
2383    protocol: ModelOutputProtocol,
2384    text: &str,
2385    started_in_think: bool,
2386    finish_reason: FinishReason,
2387) -> std::result::Result<ParsedChatModelOutput, ServerError> {
2388    match protocol {
2389        ModelOutputProtocol::Text | ModelOutputProtocol::GemmaThought => {
2390            Ok(ParsedChatModelOutput {
2391                visible: parse_model_reasoning_response(protocol, text, started_in_think)
2392                    .map_err(|error| ServerError::InternalError(error.to_string()))?,
2393                harmony_response: None,
2394            })
2395        }
2396        ModelOutputProtocol::HarmonyGptOss => {
2397            let parsed = parse_harmony_response_for_finish_reason(text, Some(finish_reason))
2398                .map_err(|error| {
2399                    ServerError::InternalError(format!(
2400                        "model output did not satisfy the GPT-OSS Harmony protocol: {error}"
2401                    ))
2402                })?;
2403            let harmony_response =
2404                parsed
2405                    .tool_call
2406                    .map(|tool_call| ferrum_types::ApiChatResponse {
2407                        message: ferrum_types::ApiChatMessage {
2408                            role: ferrum_types::ApiMessageRole::Assistant,
2409                            content: String::new(),
2410                            name: None,
2411                            tool_calls: vec![ferrum_types::ApiToolCall {
2412                                id: format!("call_{}", Uuid::new_v4().simple()),
2413                                tool_type: "function".to_string(),
2414                                function: ferrum_types::ApiFunctionCall {
2415                                    name: tool_call.name,
2416                                    arguments: tool_call.arguments_json,
2417                                },
2418                            }],
2419                            tool_call_id: None,
2420                            function_call: None,
2421                        },
2422                        finish_reason: Some("tool_calls".to_string()),
2423                    });
2424            Ok(ParsedChatModelOutput {
2425                visible: ParsedReasoningResponse {
2426                    content: parsed.content,
2427                    reasoning: parsed.reasoning_content,
2428                },
2429                harmony_response,
2430            })
2431        }
2432    }
2433}
2434
2435/// Handle streaming chat completions
2436async fn handle_chat_completions_stream(
2437    state: AppState,
2438    openai_request: ChatCompletionsRequest,
2439    inference_request: InferenceRequest,
2440    benchmark_correlation: Option<BenchmarkRequestCorrelation>,
2441) -> std::result::Result<Response, ServerError> {
2442    let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2443
2444    // Spawn task to generate tokens
2445    let engine = state.llm.clone().ok_or_else(|| {
2446        ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2447    })?;
2448    let request_id = inference_request.id.to_string();
2449    let include_stream_usage = openai_request
2450        .stream_options
2451        .as_ref()
2452        .and_then(|opts| opts.include_usage)
2453        .unwrap_or(false);
2454    let output_contract = EffectiveChatOutputContract::resolve(&openai_request);
2455    let buffer_json_object_stream = matches!(
2456        output_contract,
2457        EffectiveChatOutputContract::JsonObjectContent
2458    );
2459    let buffer_strict_json_schema_stream = matches!(
2460        output_contract,
2461        EffectiveChatOutputContract::StrictJsonSchemaContent
2462    );
2463    let stream_api_request = match inference_request.api_request.as_ref() {
2464        Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
2465        _ => api_chat_request(
2466            &openai_request,
2467            openai_request.tool_choice.as_ref(),
2468            ferrum_types::ApiToolCallProtocol::default(),
2469        ),
2470    };
2471    let buffer_structured_api_stream =
2472        ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
2473    let model_output_protocol = inference_request.sampling_params.model_output_protocol;
2474    let buffer_stream_output = buffer_json_object_stream
2475        || buffer_strict_json_schema_stream
2476        || buffer_structured_api_stream
2477        || model_output_protocol == ModelOutputProtocol::HarmonyGptOss;
2478    // R1-distill-style templates open the think block inside the prompt;
2479    // the parser must know generation starts mid-think.
2480    let started_in_think = request_started_in_reasoning(&inference_request);
2481    let mut native_projector = NativeChatOutputProjector::for_request(&inference_request);
2482    let replay_request_id = inference_request.id.to_string();
2483    let profile_request_model = openai_request.model.clone();
2484    let profile_started_at = Instant::now();
2485    let request_memory_before = request_memory_sample_before(&state);
2486    let mut stream = match engine.infer_stream(inference_request).await {
2487        Ok(stream) => stream,
2488        Err(e) => {
2489            let failure_kind = e.observability_failure_kind();
2490            let error_kind = e.observability_error_kind();
2491            let error_message = e.to_string();
2492            if let Err(err) = write_chat_request_profile_event(
2493                &state,
2494                &replay_request_id,
2495                benchmark_correlation.as_ref(),
2496                &profile_request_model,
2497                true,
2498                "chat_completions_stream_start",
2499                profile_started_at,
2500                ChatRequestProfileTiming::default(),
2501                0,
2502                None,
2503                Some("error"),
2504                Some(ProfileError {
2505                    kind: error_kind.to_string(),
2506                    message: error_message.clone(),
2507                    blocking: false,
2508                }),
2509            ) {
2510                warn!("failed to write chat stream failure profile event: {}", err);
2511            }
2512            let engine_status = if chat_resource_failure_kind(failure_kind) {
2513                Some(engine.status().await)
2514            } else {
2515                None
2516            };
2517            error!(
2518                "Stream generation failed before first chunk: {}",
2519                error_message
2520            );
2521            if let Err(err) = write_chat_request_failure_diagnostics(
2522                &state,
2523                &replay_request_id,
2524                failure_kind,
2525                "chat_completions_stream_start",
2526                error_kind,
2527                &error_message,
2528                engine_status.as_ref(),
2529            ) {
2530                warn!("failed to write chat stream failure diagnostics: {}", err);
2531            }
2532            return Err(server_error_from_ferrum_error(e));
2533        }
2534    };
2535    let request_dump_dir = state.request_dump_dir.clone();
2536    let admission_summary = state
2537        .auto_config
2538        .as_ref()
2539        .map(|config| config.admission_summary_document());
2540    let diagnostics_engine = engine.clone();
2541    let profile_state = state.clone();
2542
2543    tokio::spawn(async move {
2544        let mut current_text = String::new();
2545        let mut output_token_ids = Vec::new();
2546        let mut first_engine_chunk_received_us = None;
2547        let mut first_sse_enqueue_us = None;
2548        let mut sent_reasoning_len = 0usize;
2549        let mut sent_content_len = 0usize;
2550
2551        loop {
2552            let next = tokio::select! {
2553                biased;
2554                _ = tx.closed() => break,
2555                next = stream.next() => next,
2556            };
2557            let Some(result) = next else {
2558                break;
2559            };
2560            match result {
2561                Ok(chunk) => {
2562                    if first_engine_chunk_received_us.is_none()
2563                        && (chunk.token.is_some() || !chunk.text.is_empty())
2564                    {
2565                        first_engine_chunk_received_us = Some(elapsed_us_since(profile_started_at));
2566                    }
2567                    if let Some(token) = chunk.token {
2568                        output_token_ids.push(token);
2569                    }
2570                    if !chunk.text.is_empty() {
2571                        current_text.push_str(&chunk.text);
2572                        if let Some(projector) = native_projector.as_mut() {
2573                            projector.push(&chunk.text);
2574                        }
2575
2576                        if native_projector.is_some()
2577                            || (!buffer_stream_output
2578                                && !should_defer_model_reasoning_stream_delta(
2579                                    model_output_protocol,
2580                                    &current_text,
2581                                ))
2582                        {
2583                            let parsed_result = if let Some(projector) = native_projector.as_ref() {
2584                                Ok(ParsedReasoningResponse {
2585                                    content: projector.visible_prefix().to_owned(),
2586                                    reasoning: projector.reasoning_prefix().map(str::to_owned),
2587                                })
2588                            } else {
2589                                parse_model_reasoning_response(
2590                                    model_output_protocol,
2591                                    &current_text,
2592                                    started_in_think,
2593                                )
2594                            };
2595                            let parsed = match parsed_result {
2596                                Ok(parsed) => parsed,
2597                                Err(error) => {
2598                                    let _ = tx.send(Ok(openai_error_sse_event(
2599                                        error.to_string(),
2600                                        "internal_server_error",
2601                                        Some("model_output"),
2602                                    )));
2603                                    let _ = tx.send(Ok(Event::default().data("[DONE]")));
2604                                    break;
2605                                }
2606                            };
2607                            let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
2608                            let reasoning_delta =
2609                                stream_text_delta(full_reasoning, &mut sent_reasoning_len);
2610                            let content_delta =
2611                                stream_text_delta(&parsed.content, &mut sent_content_len);
2612                            if !reasoning_delta.is_empty() || !content_delta.is_empty() {
2613                                // Create streaming response chunk
2614                                let response_chunk = ChatCompletionsResponse {
2615                                    id: request_id.clone(),
2616                                    object: "chat.completion.chunk".to_string(),
2617                                    created: chrono::Utc::now().timestamp() as u64,
2618                                    model: openai_request.model.clone(),
2619                                    choices: vec![ChatChoice {
2620                                        index: 0,
2621                                        message: None,
2622                                        delta: Some(ChatMessage {
2623                                            role: MessageRole::Assistant,
2624                                            content: content_delta,
2625                                            reasoning: (!reasoning_delta.is_empty())
2626                                                .then_some(reasoning_delta),
2627                                            name: None,
2628                                            tool_calls: None,
2629                                            tool_call_id: None,
2630                                            function_call: None,
2631                                        }),
2632                                        finish_reason: None,
2633                                    }],
2634                                    usage: None,
2635                                };
2636
2637                                let sse_event = Event::default()
2638                                    .json_data(&response_chunk)
2639                                    .unwrap_or_else(|_| Event::default().data("error"));
2640                                if tx.send(Ok(sse_event)).is_err() {
2641                                    break;
2642                                }
2643                                first_sse_enqueue_us
2644                                    .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2645                            }
2646                        }
2647                    }
2648
2649                    if chunk.finish_reason.is_some() {
2650                        let terminal_finish_reason = chunk
2651                            .finish_reason
2652                            .expect("finish_reason presence checked above");
2653                        if let Err(err) = write_chat_prompt_token_evidence(
2654                            request_dump_dir.as_ref().map(|root| root.as_path()),
2655                            &replay_request_id,
2656                            &profile_request_model,
2657                            chunk.execution_evidence.as_ref(),
2658                        ) {
2659                            warn!("failed to write chat stream prompt-token evidence: {}", err);
2660                        }
2661                        let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
2662                        let native_projected = native_projector
2663                            .take()
2664                            .map(|projector| projector.finish(terminal_finish_reason));
2665                        let parsed_output_result =
2666                            if let Some(projected) = native_projected.as_ref() {
2667                                Ok(ParsedChatModelOutput {
2668                                    visible: projected.visible.clone(),
2669                                    harmony_response: None,
2670                                })
2671                            } else {
2672                                parse_chat_model_output(
2673                                    model_output_protocol,
2674                                    &current_text,
2675                                    started_in_think,
2676                                    terminal_finish_reason,
2677                                )
2678                            };
2679                        let parsed_model_output = match parsed_output_result {
2680                            Ok(parsed) => parsed,
2681                            Err(error) => {
2682                                let error_event = openai_error_sse_event(
2683                                    stream_validation_error_message(error),
2684                                    "internal_server_error",
2685                                    Some("model_output"),
2686                                );
2687                                let _ = tx.send(Ok(error_event));
2688                                let _ = tx.send(Ok(Event::default().data("[DONE]")));
2689                                break;
2690                            }
2691                        };
2692                        let mut parsed_final = parsed_model_output.visible;
2693                        parsed_final.content = normalize_structured_response_content(
2694                            &openai_request,
2695                            &parsed_final.content,
2696                        );
2697                        let mut structured_chat_response =
2698                            finish_reason_allows_structured_api_response(terminal_finish_reason)
2699                                .then(|| match chunk.api_response.as_ref() {
2700                                    // The native envelope owns the recipient and whether this is
2701                                    // a call at all, including a final answer containing JSON.
2702                                    _ if model_output_protocol
2703                                        == ModelOutputProtocol::HarmonyGptOss =>
2704                                    {
2705                                        parsed_model_output.harmony_response.clone()
2706                                    }
2707                                    Some(ferrum_types::ApiResponse::Chat(response)) => {
2708                                        Some(response.clone())
2709                                    }
2710                                    _ if native_projected.is_some() => native_projected
2711                                        .as_ref()
2712                                        .and_then(|projected| projected.api_response.clone()),
2713                                    _ if buffer_structured_api_stream => {
2714                                        chat_api_response_from_parsed_generated_text(
2715                                            &stream_api_request,
2716                                            &parsed_final,
2717                                            terminal_finish_reason,
2718                                        )
2719                                    }
2720                                    _ => None,
2721                                })
2722                                .flatten();
2723
2724                        if native_projected.is_none()
2725                            && matches!(
2726                                chunk.api_response,
2727                                Some(ferrum_types::ApiResponse::Chat(_))
2728                            )
2729                        {
2730                            if let Some(response) = structured_chat_response.as_mut() {
2731                                if let Err(error) = project_typed_tool_response_content(
2732                                    response,
2733                                    model_output_protocol,
2734                                    started_in_think,
2735                                ) {
2736                                    let _ = tx.send(Ok(openai_error_sse_event(
2737                                        stream_validation_error_message(error),
2738                                        "internal_server_error",
2739                                        Some("model_output"),
2740                                    )));
2741                                    let _ = tx.send(Ok(Event::default().data("[DONE]")));
2742                                    break;
2743                                }
2744                            }
2745                        }
2746
2747                        if let Some(chat_response) = structured_chat_response.as_ref() {
2748                            if let Err(e) =
2749                                validate_structured_tool_response(&openai_request, chat_response)
2750                            {
2751                                let error_event = openai_error_sse_event(
2752                                    stream_validation_error_message(e),
2753                                    "internal_server_error",
2754                                    Some("tool_choice"),
2755                                );
2756                                let _ = tx.send(Ok(error_event));
2757                                let _ = tx.send(Ok(Event::default().data("[DONE]")));
2758                                break;
2759                            }
2760                        } else if tool_choice_required(&openai_request) {
2761                            log_required_tool_choice_failure(
2762                                &openai_request,
2763                                &parsed_final.content,
2764                                parsed_final.reasoning.as_deref(),
2765                            );
2766                            let error_event = openai_error_sse_event(
2767                                "model output did not satisfy required tool_choice",
2768                                "invalid_request_error",
2769                                Some("tool_choice"),
2770                            );
2771                            let _ = tx.send(Ok(error_event));
2772                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
2773                            break;
2774                        }
2775                        if let Err(e) = validate_hard_structured_response(
2776                            &openai_request,
2777                            &parsed_final.content,
2778                            structured_chat_response.as_ref(),
2779                        ) {
2780                            let error_event = openai_error_sse_event(
2781                                stream_validation_error_message(e),
2782                                "internal_server_error",
2783                                structured_response_error_param(output_contract),
2784                            );
2785                            let _ = tx.send(Ok(error_event));
2786                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
2787                            break;
2788                        }
2789
2790                        if let Some(chat_response) = structured_chat_response.as_ref() {
2791                            let mut delta = openai_chat_delta_from_api(&chat_response.message);
2792                            if native_projected.is_some() {
2793                                delta.content =
2794                                    stream_text_delta(&delta.content, &mut sent_content_len);
2795                            }
2796                            if delta.reasoning.is_none() {
2797                                delta.reasoning = parsed_final.reasoning.clone();
2798                            }
2799                            if native_projected.is_some() {
2800                                let reasoning = stream_text_delta(
2801                                    delta.reasoning.as_deref().unwrap_or(""),
2802                                    &mut sent_reasoning_len,
2803                                );
2804                                delta.reasoning = (!reasoning.is_empty()).then_some(reasoning);
2805                            }
2806                            let response_chunk = ChatCompletionsResponse {
2807                                id: request_id.clone(),
2808                                object: "chat.completion.chunk".to_string(),
2809                                created: chrono::Utc::now().timestamp() as u64,
2810                                model: openai_request.model.clone(),
2811                                choices: vec![ChatChoice {
2812                                    index: 0,
2813                                    message: None,
2814                                    delta: Some(delta),
2815                                    finish_reason: None,
2816                                }],
2817                                usage: None,
2818                            };
2819
2820                            let sse_event = Event::default()
2821                                .json_data(&response_chunk)
2822                                .unwrap_or_else(|_| Event::default().data("error"));
2823                            if tx.send(Ok(sse_event)).is_err() {
2824                                break;
2825                            }
2826                            first_sse_enqueue_us
2827                                .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2828                        } else if buffer_structured_api_stream
2829                            && parsed_final.content.trim().is_empty()
2830                            // A token-limited response may contain only reasoning.
2831                            // Preserve its terminal reason and usage, as sync does;
2832                            // required tool and hard content contracts were checked above.
2833                            && terminal_finish_reason != FinishReason::Length
2834                        {
2835                            let error_event = openai_error_sse_event(
2836                                "model output did not satisfy tool/function call request",
2837                                "internal_server_error",
2838                                Some("tool_choice"),
2839                            );
2840                            let _ = tx.send(Ok(error_event));
2841                            let _ = tx.send(Ok(Event::default().data("[DONE]")));
2842                            break;
2843                        } else if !current_text.is_empty() {
2844                            // Flush safe text held with an incomplete trailing
2845                            // marker, including when text and finish share a chunk.
2846                            // Buffered streams have sent lengths of zero.
2847                            let content_delta =
2848                                stream_text_delta(&parsed_final.content, &mut sent_content_len);
2849                            let reasoning_delta = stream_text_delta(
2850                                parsed_final.reasoning.as_deref().unwrap_or(""),
2851                                &mut sent_reasoning_len,
2852                            );
2853                            if !content_delta.is_empty() || !reasoning_delta.is_empty() {
2854                                let response_chunk = ChatCompletionsResponse {
2855                                    id: request_id.clone(),
2856                                    object: "chat.completion.chunk".to_string(),
2857                                    created: chrono::Utc::now().timestamp() as u64,
2858                                    model: openai_request.model.clone(),
2859                                    choices: vec![ChatChoice {
2860                                        index: 0,
2861                                        message: None,
2862                                        delta: Some(ChatMessage {
2863                                            role: MessageRole::Assistant,
2864                                            content: content_delta,
2865                                            reasoning: (!reasoning_delta.is_empty())
2866                                                .then_some(reasoning_delta),
2867                                            name: None,
2868                                            tool_calls: None,
2869                                            tool_call_id: None,
2870                                            function_call: None,
2871                                        }),
2872                                        finish_reason: None,
2873                                    }],
2874                                    usage: None,
2875                                };
2876
2877                                let sse_event = Event::default()
2878                                    .json_data(&response_chunk)
2879                                    .unwrap_or_else(|_| Event::default().data("error"));
2880                                if tx.send(Ok(sse_event)).is_err() {
2881                                    break;
2882                                }
2883                                first_sse_enqueue_us
2884                                    .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2885                            }
2886                        }
2887                        // Send final chunk. OpenAI-style streaming
2888                        // clients (e.g. `vllm bench serve`) blindly
2889                        // read `choices[0]["delta"]` on every chunk
2890                        // that has any `choices` entries, so the
2891                        // last chunk must include `delta` even when
2892                        // empty. Skipping it triggers
2893                        // `KeyError: 'delta'` on the client side
2894                        // and the request is reported as failed
2895                        // despite returning a 200 with content.
2896                        let final_finish_reason = structured_chat_response
2897                            .as_ref()
2898                            .and_then(|response| response.finish_reason.clone())
2899                            .or_else(|| chunk.finish_reason.as_ref().map(finish_reason_to_string))
2900                            .or(Some("length".to_string()));
2901                        let final_chunk = ChatCompletionsResponse {
2902                            id: request_id.clone(),
2903                            object: "chat.completion.chunk".to_string(),
2904                            created: chrono::Utc::now().timestamp() as u64,
2905                            model: openai_request.model.clone(),
2906                            choices: vec![ChatChoice {
2907                                index: 0,
2908                                message: None,
2909                                delta: Some(ChatMessage {
2910                                    role: MessageRole::Assistant,
2911                                    content: String::new(),
2912                                    reasoning: None,
2913                                    name: None,
2914                                    tool_calls: None,
2915                                    tool_call_id: None,
2916                                    function_call: None,
2917                                }),
2918                                finish_reason: final_finish_reason.clone(),
2919                            }],
2920                            usage: None,
2921                        };
2922
2923                        let final_event = Event::default()
2924                            .json_data(&final_chunk)
2925                            .unwrap_or_else(|_| Event::default().data("error"));
2926                        if tx.send(Ok(final_event)).is_ok() {
2927                            first_sse_enqueue_us
2928                                .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2929                        }
2930                        let completion_token_count = chunk
2931                            .usage
2932                            .as_ref()
2933                            .map(|usage| usage.completion_tokens)
2934                            .unwrap_or(output_token_ids.len());
2935                        let replay_output_token_ids = chunk
2936                            .execution_evidence
2937                            .as_ref()
2938                            .map(|evidence| evidence.output_token_ids.as_slice())
2939                            .filter(|tokens| tokens.len() == completion_token_count)
2940                            .unwrap_or(output_token_ids.as_slice());
2941                        if let Err(err) = write_chat_request_completion_replay_bundle(
2942                            request_dump_dir.as_ref().map(|root| root.as_path()),
2943                            &replay_request_id,
2944                            &parsed_final.content,
2945                            replay_output_token_ids,
2946                            final_finish_reason.as_deref(),
2947                        ) {
2948                            warn!("failed to write chat stream replay bundle: {}", err);
2949                        }
2950                        if let Err(err) = write_chat_request_profile_event(
2951                            &profile_state,
2952                            &replay_request_id,
2953                            benchmark_correlation.as_ref(),
2954                            &profile_request_model,
2955                            true,
2956                            "chat_completions_stream_complete",
2957                            profile_started_at,
2958                            ChatRequestProfileTiming {
2959                                engine_evidence: chunk.execution_evidence.as_ref(),
2960                                first_engine_chunk_received_us,
2961                                first_sse_enqueue_us,
2962                            },
2963                            completion_token_count,
2964                            chunk.usage.as_ref(),
2965                            final_finish_reason.as_deref(),
2966                            None,
2967                        ) {
2968                            warn!("failed to write chat stream profile event: {}", err);
2969                        }
2970                        if let Err(err) = maybe_write_first_request_memory_stage(
2971                            &profile_state,
2972                            &replay_request_id,
2973                            benchmark_correlation.as_ref(),
2974                            &profile_request_model,
2975                            true,
2976                            profile_started_at,
2977                            request_memory_before,
2978                        ) {
2979                            warn!("failed to write chat stream memory profile event: {}", err);
2980                        }
2981                        if include_stream_usage && usage.is_some() {
2982                            let usage_chunk = ChatCompletionsResponse {
2983                                id: request_id.clone(),
2984                                object: "chat.completion.chunk".to_string(),
2985                                created: chrono::Utc::now().timestamp() as u64,
2986                                model: openai_request.model.clone(),
2987                                choices: vec![],
2988                                usage,
2989                            };
2990                            let usage_event = Event::default()
2991                                .json_data(&usage_chunk)
2992                                .unwrap_or_else(|_| Event::default().data("error"));
2993                            let _ = tx.send(Ok(usage_event));
2994                        }
2995                        let _ = tx.send(Ok(Event::default().data("[DONE]")));
2996                        break;
2997                    }
2998                }
2999                Err(e) => {
3000                    let failure_kind = e.observability_failure_kind();
3001                    let error_kind = e.observability_error_kind();
3002                    let error_message = e.to_string();
3003                    let engine_status = if chat_resource_failure_kind(failure_kind) {
3004                        Some(diagnostics_engine.status().await)
3005                    } else {
3006                        None
3007                    };
3008                    error!("Stream generation error: {}", error_message);
3009                    if let Err(err) = write_chat_request_profile_event(
3010                        &profile_state,
3011                        &replay_request_id,
3012                        benchmark_correlation.as_ref(),
3013                        &profile_request_model,
3014                        true,
3015                        "chat_completions_stream_next",
3016                        profile_started_at,
3017                        ChatRequestProfileTiming {
3018                            engine_evidence: None,
3019                            first_engine_chunk_received_us,
3020                            first_sse_enqueue_us,
3021                        },
3022                        output_token_ids.len(),
3023                        None,
3024                        Some("error"),
3025                        Some(ProfileError {
3026                            kind: error_kind.to_string(),
3027                            message: error_message.clone(),
3028                            blocking: false,
3029                        }),
3030                    ) {
3031                        warn!("failed to write chat stream chunk profile event: {}", err);
3032                    }
3033                    if let Err(err) = write_chat_request_failure_diagnostics_at_root(
3034                        request_dump_dir.as_ref().map(|root| root.as_path()),
3035                        admission_summary.as_ref(),
3036                        engine_status.as_ref(),
3037                        &replay_request_id,
3038                        failure_kind,
3039                        "chat_completions_stream_next",
3040                        error_kind,
3041                        &error_message,
3042                    ) {
3043                        warn!(
3044                            "failed to write chat stream chunk failure diagnostics: {}",
3045                            err
3046                        );
3047                    }
3048                    let _ = tx.send(Ok(openai_error_sse_event(
3049                        error_message,
3050                        "internal_server_error",
3051                        None,
3052                    )));
3053                    let _ = tx.send(Ok(Event::default().data("[DONE]")));
3054                    break;
3055                }
3056            }
3057        }
3058    });
3059
3060    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
3061    let sse_stream = Sse::new(stream);
3062
3063    Ok(sse_stream.into_response())
3064}
3065
3066/// Handle non-streaming chat completions
3067async fn handle_chat_completions_sync(
3068    state: AppState,
3069    openai_request: ChatCompletionsRequest,
3070    inference_request: InferenceRequest,
3071    session_context: Option<SessionContext>,
3072    benchmark_correlation: Option<BenchmarkRequestCorrelation>,
3073) -> std::result::Result<Response, ServerError> {
3074    info!("Processing non-streaming chat completion");
3075
3076    let engine = state.llm.clone().ok_or_else(|| {
3077        ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
3078    })?;
3079    let request_chat_api = inference_request
3080        .api_request
3081        .as_ref()
3082        .and_then(|api_request| match api_request {
3083            ferrum_types::ApiRequest::Chat(chat_request) => {
3084                ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
3085                    .then(|| chat_request.clone())
3086            }
3087            _ => None,
3088        });
3089    let model_output_protocol = inference_request.sampling_params.model_output_protocol;
3090    // R1-distill-style templates open the think block inside the prompt.
3091    let started_in_think = request_started_in_reasoning(&inference_request);
3092    let mut native_projector = NativeChatOutputProjector::for_request(&inference_request);
3093    let replay_request_id = inference_request.id.to_string();
3094    let profile_request_model = openai_request.model.clone();
3095    let profile_started_at = Instant::now();
3096    let request_memory_before = request_memory_sample_before(&state);
3097    match engine.infer(inference_request).await {
3098        Ok(output) => {
3099            let InferenceResponse {
3100                text: output_text,
3101                tokens,
3102                finish_reason,
3103                usage,
3104                api_response,
3105                execution_evidence,
3106                ..
3107            } = output;
3108            if let Err(err) = write_chat_prompt_token_evidence(
3109                state.request_dump_dir.as_ref().map(|root| root.as_path()),
3110                &replay_request_id,
3111                &profile_request_model,
3112                execution_evidence.as_ref(),
3113            ) {
3114                warn!("failed to write chat prompt-token evidence: {}", err);
3115            }
3116
3117            // OpenAI stop strings mark a boundary and are not included in the
3118            // returned completion. Hard structured formats are never repaired
3119            // here; malformed output must fail the response contract below.
3120            let stop_sequences = openai_request.stop.clone().unwrap_or_default();
3121            let content = strip_after_stop(&output_text, &stop_sequences);
3122            let native_projected = native_projector.take().map(|mut projector| {
3123                projector.push(&content);
3124                projector.finish(finish_reason)
3125            });
3126            let parsed_model_output = if let Some(projected) = native_projected.as_ref() {
3127                ParsedChatModelOutput {
3128                    visible: projected.visible.clone(),
3129                    harmony_response: None,
3130                }
3131            } else {
3132                parse_chat_model_output(
3133                    model_output_protocol,
3134                    &content,
3135                    started_in_think,
3136                    finish_reason,
3137                )?
3138            };
3139            let parsed = parsed_model_output.visible;
3140            let visible_content =
3141                normalize_structured_response_content(&openai_request, &parsed.content);
3142            let mut message = ChatMessage {
3143                role: MessageRole::Assistant,
3144                content: visible_content,
3145                reasoning: parsed.reasoning.clone(),
3146                name: None,
3147                tool_calls: None,
3148                tool_call_id: None,
3149                function_call: None,
3150            };
3151            let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
3152            let mut structured_chat_response =
3153                finish_reason_allows_structured_api_response(finish_reason)
3154                    .then(|| match api_response.as_ref() {
3155                        // The native envelope owns the recipient and whether this is
3156                        // a call at all, including a final answer containing JSON.
3157                        _ if model_output_protocol == ModelOutputProtocol::HarmonyGptOss => {
3158                            parsed_model_output.harmony_response.clone()
3159                        }
3160                        Some(ferrum_types::ApiResponse::Chat(chat_response)) => {
3161                            Some(chat_response.clone())
3162                        }
3163                        _ if native_projected.is_some() => native_projected
3164                            .as_ref()
3165                            .and_then(|projected| projected.api_response.clone()),
3166                        _ => match request_chat_api.as_ref() {
3167                            Some(chat_request) => chat_api_response_from_parsed_generated_text(
3168                                chat_request,
3169                                &parsed,
3170                                finish_reason,
3171                            ),
3172                            _ => None,
3173                        },
3174                    })
3175                    .flatten();
3176            if native_projected.is_none()
3177                && matches!(api_response, Some(ferrum_types::ApiResponse::Chat(_)))
3178            {
3179                if let Some(response) = structured_chat_response.as_mut() {
3180                    project_typed_tool_response_content(
3181                        response,
3182                        model_output_protocol,
3183                        started_in_think,
3184                    )?;
3185                }
3186            }
3187            if let Some(chat_response) = structured_chat_response.as_ref() {
3188                if let Err(error) =
3189                    validate_structured_tool_response(&openai_request, chat_response)
3190                {
3191                    if let Err(err) = write_chat_request_profile_event(
3192                        &state,
3193                        &replay_request_id,
3194                        benchmark_correlation.as_ref(),
3195                        &profile_request_model,
3196                        false,
3197                        "chat_completions_sync_tool_contract",
3198                        profile_started_at,
3199                        ChatRequestProfileTiming {
3200                            engine_evidence: execution_evidence.as_ref(),
3201                            ..Default::default()
3202                        },
3203                        tokens.len(),
3204                        Some(&usage),
3205                        Some("error"),
3206                        Some(ProfileError {
3207                            kind: "tool_contract_failure".to_string(),
3208                            message: format!("{error:?}"),
3209                            blocking: true,
3210                        }),
3211                    ) {
3212                        warn!("failed to write chat tool-contract profile event: {}", err);
3213                    }
3214                    return Err(error);
3215                }
3216                message = openai_chat_message_from_api(&chat_response.message);
3217                if message.reasoning.is_none() {
3218                    message.reasoning = parsed.reasoning.clone();
3219                }
3220                if let Some(reason) = &chat_response.finish_reason {
3221                    openai_finish_reason = reason.clone();
3222                }
3223            } else if tool_choice_required(&openai_request) {
3224                log_required_tool_choice_failure(
3225                    &openai_request,
3226                    &parsed.content,
3227                    parsed.reasoning.as_deref(),
3228                );
3229                if let Err(err) = write_chat_request_profile_event(
3230                    &state,
3231                    &replay_request_id,
3232                    benchmark_correlation.as_ref(),
3233                    &profile_request_model,
3234                    false,
3235                    "chat_completions_sync_tool_choice",
3236                    profile_started_at,
3237                    ChatRequestProfileTiming {
3238                        engine_evidence: execution_evidence.as_ref(),
3239                        ..Default::default()
3240                    },
3241                    tokens.len(),
3242                    Some(&usage),
3243                    Some("error"),
3244                    Some(ProfileError {
3245                        kind: "required_tool_failure".to_string(),
3246                        message: "model output did not satisfy required tool_choice".to_string(),
3247                        blocking: true,
3248                    }),
3249                ) {
3250                    warn!("failed to write chat tool-choice profile event: {}", err);
3251                }
3252                return Err(ServerError::invalid_request(
3253                    "model output did not satisfy required tool_choice",
3254                    Some("tool_choice"),
3255                ));
3256            }
3257            if let Err(error) = validate_hard_structured_response(
3258                &openai_request,
3259                &message.content,
3260                structured_chat_response.as_ref(),
3261            ) {
3262                if let Err(err) = write_chat_request_profile_event(
3263                    &state,
3264                    &replay_request_id,
3265                    benchmark_correlation.as_ref(),
3266                    &profile_request_model,
3267                    false,
3268                    "chat_completions_sync_structured_output",
3269                    profile_started_at,
3270                    ChatRequestProfileTiming {
3271                        engine_evidence: execution_evidence.as_ref(),
3272                        ..Default::default()
3273                    },
3274                    tokens.len(),
3275                    Some(&usage),
3276                    Some("error"),
3277                    Some(ProfileError {
3278                        kind: "structured_output_failure".to_string(),
3279                        message: format!("{error:?}"),
3280                        blocking: true,
3281                    }),
3282                ) {
3283                    warn!("failed to write chat strict-schema profile event: {}", err);
3284                }
3285                return Err(error);
3286            }
3287            if let Err(err) = write_chat_request_completion_replay_bundle(
3288                state.request_dump_dir.as_ref().map(|root| root.as_path()),
3289                &replay_request_id,
3290                &message.content,
3291                &tokens,
3292                Some(&openai_finish_reason),
3293            ) {
3294                warn!("failed to write chat completion replay bundle: {}", err);
3295            }
3296            if let Err(err) = write_chat_request_profile_event(
3297                &state,
3298                &replay_request_id,
3299                benchmark_correlation.as_ref(),
3300                &profile_request_model,
3301                false,
3302                "chat_completions_sync_complete",
3303                profile_started_at,
3304                ChatRequestProfileTiming {
3305                    engine_evidence: execution_evidence.as_ref(),
3306                    ..Default::default()
3307                },
3308                tokens.len(),
3309                Some(&usage),
3310                Some(&openai_finish_reason),
3311                None,
3312            ) {
3313                warn!("failed to write chat sync profile event: {}", err);
3314            }
3315            if let Err(err) = maybe_write_first_request_memory_stage(
3316                &state,
3317                &replay_request_id,
3318                benchmark_correlation.as_ref(),
3319                &profile_request_model,
3320                false,
3321                profile_started_at,
3322                request_memory_before,
3323            ) {
3324                warn!("failed to write chat sync memory profile event: {}", err);
3325            }
3326            state
3327                .cache
3328                .update_session(session_context, message.clone(), &CachePolicy::current());
3329            let response = ChatCompletionsResponse {
3330                id: replay_request_id,
3331                object: "chat.completion".to_string(),
3332                created: chrono::Utc::now().timestamp() as u64,
3333                model: openai_request.model,
3334                choices: vec![ChatChoice {
3335                    index: 0,
3336                    message: Some(message),
3337                    delta: None,
3338                    finish_reason: Some(openai_finish_reason),
3339                }],
3340                usage: Some(openai_usage_from_token_usage(&usage)),
3341            };
3342
3343            Ok(Json(response).into_response())
3344        }
3345        Err(e) => {
3346            let failure_kind = e.observability_failure_kind();
3347            let error_kind = e.observability_error_kind();
3348            let error_message = e.to_string();
3349            let engine_status = if chat_resource_failure_kind(failure_kind) {
3350                Some(engine.status().await)
3351            } else {
3352                None
3353            };
3354            error!("Generation failed: {}", error_message);
3355            if let Err(err) = write_chat_request_profile_event(
3356                &state,
3357                &replay_request_id,
3358                benchmark_correlation.as_ref(),
3359                &profile_request_model,
3360                false,
3361                "chat_completions_sync",
3362                profile_started_at,
3363                ChatRequestProfileTiming::default(),
3364                0,
3365                None,
3366                Some("error"),
3367                Some(ProfileError {
3368                    kind: error_kind.to_string(),
3369                    message: error_message.clone(),
3370                    blocking: false,
3371                }),
3372            ) {
3373                warn!("failed to write chat sync failure profile event: {}", err);
3374            }
3375            if let Err(err) = write_chat_request_failure_diagnostics(
3376                &state,
3377                &replay_request_id,
3378                failure_kind,
3379                "chat_completions_sync",
3380                error_kind,
3381                &error_message,
3382                engine_status.as_ref(),
3383            ) {
3384                warn!(
3385                    "failed to write chat generation failure diagnostics: {}",
3386                    err
3387                );
3388            }
3389            Err(server_error_from_ferrum_error(e))
3390        }
3391    }
3392}
3393
3394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3395enum EffectiveChatOutputContract {
3396    RequiredToolCall,
3397    StrictJsonSchemaContent,
3398    JsonObjectContent,
3399    BestEffortJsonSchemaContent,
3400    Text,
3401}
3402
3403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3404enum ChatOutputBudget {
3405    AutoCeiling(u32),
3406    Explicit(u32),
3407}
3408
3409impl ChatOutputBudget {
3410    fn resolve(request: &ChatCompletionsRequest) -> Self {
3411        request
3412            .max_completion_tokens
3413            .or(request.max_tokens)
3414            .map(Self::Explicit)
3415            .unwrap_or(Self::AutoCeiling(DEFAULT_COMPLETION_MAX_TOKENS))
3416    }
3417
3418    const fn ceiling(self) -> u32 {
3419        match self {
3420            Self::AutoCeiling(value) | Self::Explicit(value) => value,
3421        }
3422    }
3423
3424    const fn is_auto(self) -> bool {
3425        matches!(self, Self::AutoCeiling(_))
3426    }
3427}
3428
3429impl EffectiveChatOutputContract {
3430    fn resolve(request: &ChatCompletionsRequest) -> Self {
3431        if tool_choice_required(request) {
3432            return Self::RequiredToolCall;
3433        }
3434        let Some(format) = request.response_format.as_ref() else {
3435            return Self::Text;
3436        };
3437        match format.format_type.as_str() {
3438            "json_schema"
3439                if format
3440                    .json_schema
3441                    .as_ref()
3442                    .and_then(|schema| schema.strict)
3443                    .unwrap_or(false) =>
3444            {
3445                Self::StrictJsonSchemaContent
3446            }
3447            "json_schema" => Self::BestEffortJsonSchemaContent,
3448            "json_object" => Self::JsonObjectContent,
3449            _ => Self::Text,
3450        }
3451    }
3452
3453    fn accepts_requested_response_format(self) -> bool {
3454        !matches!(self, Self::RequiredToolCall)
3455    }
3456}
3457
3458/// Convert OpenAI chat request to internal inference request
3459#[allow(dead_code)]
3460fn convert_chat_request(
3461    request: &ChatCompletionsRequest,
3462) -> ferrum_types::Result<InferenceRequest> {
3463    convert_chat_request_with_template_model(request, &request.model, None)
3464}
3465
3466fn request_started_in_reasoning(request: &InferenceRequest) -> bool {
3467    request
3468        .metadata
3469        .get(PROMPT_OPENED_REASONING_METADATA_KEY)
3470        .and_then(serde_json::Value::as_bool)
3471        .unwrap_or_else(|| {
3472            // Requests constructed outside the chat converter retain their
3473            // legacy parser behavior. Rendered chat requests carry the actual
3474            // generation-suffix state, independent of user/schema text.
3475            has_unclosed_model_reasoning_block(
3476                request.sampling_params.model_output_protocol,
3477                &request.prompt,
3478            )
3479        })
3480}
3481
3482/// Convert OpenAI chat request to internal inference request.
3483///
3484/// `template_model_id` is the loaded model id used for prompt-template family
3485/// detection. The request `model` field may be an OpenAI-compatible alias such
3486/// as "ferrum"; using it for template selection can feed a fallback prompt to
3487/// a Qwen/Llama model.
3488fn convert_chat_request_with_template_model(
3489    request: &ChatCompletionsRequest,
3490    template_model_id: &str,
3491    model_template: Option<&ModelChatTemplate>,
3492) -> ferrum_types::Result<InferenceRequest> {
3493    convert_chat_request_with_template_model_and_default(
3494        request,
3495        template_model_id,
3496        model_template,
3497        None,
3498        true,
3499        None,
3500    )
3501}
3502
3503fn convert_chat_request_with_template_model_and_default(
3504    request: &ChatCompletionsRequest,
3505    template_model_id: &str,
3506    model_template: Option<&ModelChatTemplate>,
3507    default_enable_thinking: Option<bool>,
3508    interleaved_system_coalescing: bool,
3509    message_phases: Option<&[Option<AssistantMessagePhase>]>,
3510) -> ferrum_types::Result<InferenceRequest> {
3511    let no_tools: &[ChatTool] = &[];
3512    let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
3513        no_tools
3514    } else {
3515        request.tools.as_deref().unwrap_or_default()
3516    };
3517    let default_tool_choice =
3518        default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
3519    let effective_tool_choice = request
3520        .tool_choice
3521        .as_ref()
3522        .or(default_tool_choice.as_ref());
3523    let functions = request.functions.as_deref().unwrap_or_default();
3524    let model_output_protocol = model_template
3525        .map(|template| template.output_protocol)
3526        .unwrap_or(ModelOutputProtocol::Text);
3527    let output_contract = EffectiveChatOutputContract::resolve(request);
3528    let output_budget = ChatOutputBudget::resolve(request);
3529    let tool_call_protocol = model_template
3530        .map(|template| {
3531            // Harmony owns its channel/recipient envelope independently of the
3532            // generic native JSON tool grammar.
3533            if model_output_protocol == ModelOutputProtocol::HarmonyGptOss
3534                && template.tool_call_protocol == ferrum_types::ApiToolCallProtocol::NativeJson
3535            {
3536                ferrum_types::ApiToolCallProtocol::Json
3537            } else {
3538                template.tool_call_protocol
3539            }
3540        })
3541        .unwrap_or_default();
3542    let api_chat = api_chat_request(request, effective_tool_choice, tool_call_protocol);
3543    let native_tool_call_contract = api_chat.requires_native_tool_call();
3544    // Harmony tool calls own their complete channel/message/call envelope.
3545    // Applying the generic tool-argument JSON grammar at token zero would
3546    // mask that envelope and force the model to emit bare arguments instead.
3547    let forced_response_format = (model_output_protocol != ModelOutputProtocol::HarmonyGptOss
3548        && !native_tool_call_contract)
3549        .then(|| forced_tool_choice_response_format(request))
3550        .flatten();
3551    let hard_tool_call_contract = forced_response_format.is_some() || native_tool_call_contract;
3552    let requested_response_format = output_contract
3553        .accepts_requested_response_format()
3554        .then(|| requested_response_format_for_sampling(request))
3555        .transpose()?
3556        .flatten();
3557    let chat_template_options =
3558        chat_template_options_for_request(request, model_template, default_enable_thinking)?;
3559    let response_format = forced_response_format
3560        .or(requested_response_format)
3561        .unwrap_or(ferrum_types::ResponseFormat::Text);
3562    let model_generated_thinking = model_template.is_some_and(|template| {
3563        template.reasoning_protocol == ModelReasoningProtocol::ModelGenerated
3564            && template.reasoning_enabled(chat_template_options.enable_thinking)
3565    });
3566    let reasoning_enabled = model_template
3567        .is_some_and(|template| template.reasoning_enabled(chat_template_options.enable_thinking));
3568    let (render_messages, render_message_phases) = render_messages_with_response_format_instruction(
3569        request,
3570        output_contract,
3571        reasoning_enabled,
3572        model_template,
3573        message_phases,
3574    );
3575    let rendered_prompt = if tools.is_empty() && functions.is_empty() {
3576        render_chat_prompt_with_model_template_options_and_compatibility_with_prefill(
3577            &render_messages,
3578            template_model_id,
3579            model_template,
3580            &chat_template_options,
3581            interleaved_system_coalescing,
3582            Some(&render_message_phases),
3583        )?
3584    } else {
3585        render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill(
3586            &render_messages,
3587            template_model_id,
3588            model_template,
3589            &chat_template_options,
3590            tools,
3591            effective_tool_choice,
3592            functions,
3593            request.function_call.as_ref(),
3594            interleaved_system_coalescing,
3595            Some(&render_message_phases),
3596        )?
3597    };
3598    let prompt = rendered_prompt.text;
3599    let prompt_opened_thinking = rendered_prompt.reasoning_prefill;
3600    let mut metadata = HashMap::new();
3601    metadata.insert(
3602        PROMPT_OPENED_REASONING_METADATA_KEY.to_string(),
3603        serde_json::Value::Bool(prompt_opened_thinking),
3604    );
3605    metadata.insert(
3606        "openai_messages".to_string(),
3607        serde_json::to_value(&request.messages)?,
3608    );
3609    if let Some(tools) = &request.tools {
3610        metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
3611    }
3612    if let Some(tool_choice) = effective_tool_choice {
3613        metadata.insert(
3614            "openai_tool_choice".to_string(),
3615            serde_json::to_value(tool_choice)?,
3616        );
3617    }
3618    if let Some(functions) = &request.functions {
3619        metadata.insert(
3620            "openai_legacy_functions".to_string(),
3621            serde_json::to_value(functions)?,
3622        );
3623    }
3624    if let Some(function_call) = &request.function_call {
3625        metadata.insert(
3626            "openai_legacy_function_call".to_string(),
3627            serde_json::to_value(function_call)?,
3628        );
3629    }
3630    if request.ignore_eos.unwrap_or(false) {
3631        metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
3632    }
3633    if output_budget.is_auto() {
3634        metadata.insert(
3635            DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
3636            serde_json::json!(true),
3637        );
3638    }
3639    let reasoning_markers = model_reasoning_markers(model_output_protocol);
3640    if !prompt_opened_thinking {
3641        let mut forbidden = reasoning_markers
3642            .map(|(_, close)| vec![close.to_string()])
3643            .unwrap_or_default();
3644        if hard_tool_call_contract {
3645            for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
3646                push_unique_forbidden_token_text(&mut forbidden, token_text);
3647            }
3648            if let Some(eos) = model_template.as_ref().and_then(|template| {
3649                template
3650                    .eos_token
3651                    .as_deref()
3652                    .filter(|token| !token.is_empty())
3653            }) {
3654                push_unique_forbidden_token_text(&mut forbidden, eos);
3655            }
3656        }
3657        if model_output_protocol == ModelOutputProtocol::Text
3658            && chat_template_options.enable_thinking == Some(false)
3659            && model_template
3660                .is_some_and(|template| template.reasoning_protocol.supports_reasoning())
3661        {
3662            push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
3663        }
3664        metadata.insert(
3665            INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
3666            serde_json::json!(forbidden),
3667        );
3668    }
3669    let structured_output =
3670        !matches!(response_format, ferrum_types::ResponseFormat::Text) || native_tool_call_contract;
3671    let structured_output_after_reasoning = structured_output
3672        && model_output_protocol == ModelOutputProtocol::Text
3673        && (prompt_opened_thinking || model_generated_thinking);
3674    let structured_output_start =
3675        if structured_output && model_output_protocol == ModelOutputProtocol::HarmonyGptOss {
3676            StructuredOutputStart::HarmonyFinal
3677        } else if structured_output && model_output_protocol == ModelOutputProtocol::GemmaThought {
3678            let (opening, closing) = reasoning_markers.expect("Gemma thought markers");
3679            if prompt_opened_thinking {
3680                StructuredOutputStart::AfterDelimiter(closing.to_string())
3681            } else if prompt.trim_end().ends_with(closing) {
3682                StructuredOutputStart::Immediate
3683            } else {
3684                StructuredOutputStart::AfterReasoningEnvelope {
3685                    opening: opening.to_string(),
3686                    closing: closing.to_string(),
3687                    allow_reasoning: reasoning_enabled,
3688                }
3689            }
3690        } else if structured_output_after_reasoning {
3691            StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
3692        } else {
3693            StructuredOutputStart::Immediate
3694        };
3695    let delayed_grammar = matches!(
3696        structured_output_start,
3697        StructuredOutputStart::AfterDelimiter(_)
3698            | StructuredOutputStart::AfterReasoningEnvelope { .. }
3699    );
3700    let response_completion_boundary = if let Some((_, closing)) =
3701        reasoning_markers.filter(|_| prompt_opened_thinking || delayed_grammar)
3702    {
3703        ResponseCompletionBoundary::AfterDelimiterAndPayload {
3704            delimiter: closing.to_string(),
3705            alternate_envelope: api_chat.generated_response_envelope(),
3706        }
3707    } else {
3708        ResponseCompletionBoundary::Immediate
3709    };
3710
3711    Ok(InferenceRequest {
3712        id: RequestId(Uuid::new_v4()),
3713        model_id: ModelId(request.model.clone()),
3714        prompt,
3715        sampling_params: SamplingParams {
3716            max_tokens: output_budget.ceiling() as usize,
3717            temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
3718            top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
3719            top_k: request
3720                .top_k
3721                .filter(|value| *value > 0)
3722                .and_then(|value| usize::try_from(value).ok()),
3723            repetition_penalty: request
3724                .repetition_penalty
3725                .unwrap_or(DEFAULT_CHAT_REPETITION_PENALTY),
3726            presence_penalty: request.presence_penalty.unwrap_or(0.0),
3727            frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
3728            stop_sequences: request.stop.clone().unwrap_or_default(),
3729            seed: request.seed,
3730            min_p: request.min_p.filter(|value| *value > 0.0),
3731            tfs: None,
3732            typical_p: None,
3733            mirostat: None,
3734            response_format,
3735            structured_output_start,
3736            response_completion_boundary,
3737            model_output_protocol,
3738        },
3739        stream: request.stream.unwrap_or(false),
3740        priority: Priority::Normal, // Default priority
3741        client_id: None,
3742        session_id: None,
3743        created_at: chrono::Utc::now(),
3744        api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
3745        evidence_request: Default::default(),
3746        metadata,
3747    })
3748}
3749
3750fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
3751    if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
3752        tokens.push(token.to_string());
3753    }
3754}
3755
3756fn default_auto_tool_choice_for_tools(
3757    tools: &[ChatTool],
3758    choice: Option<&ToolChoice>,
3759) -> Option<ToolChoice> {
3760    if choice.is_none() && !tools.is_empty() {
3761        Some(ToolChoice::Mode("auto".to_string()))
3762    } else {
3763        None
3764    }
3765}
3766
3767fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
3768    matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
3769}
3770
3771fn tool_choice_none_hides_tools(
3772    choice: Option<&ToolChoice>,
3773    model_template: Option<&ModelChatTemplate>,
3774) -> bool {
3775    tool_choice_none(choice)
3776        && model_template
3777            .map(|template| template.template.contains("tools_in_user_message"))
3778            .unwrap_or(false)
3779}
3780
3781fn chat_template_options_for_request(
3782    request: &ChatCompletionsRequest,
3783    model_template: Option<&ModelChatTemplate>,
3784    default_enable_thinking: Option<bool>,
3785) -> ferrum_types::Result<ChatTemplateOptions> {
3786    let mut options = ChatTemplateOptions::default_for_template(model_template);
3787    let kwargs = request.chat_template_kwargs.as_ref();
3788    let explicit_thinking = kwargs
3789        .and_then(|values| values.get("enable_thinking"))
3790        .filter(|value| !value.is_null())
3791        .map(|value| {
3792            value.as_bool().ok_or_else(|| {
3793                Error::invalid_request("chat_template_kwargs.enable_thinking must be a boolean")
3794            })
3795        })
3796        .transpose()?;
3797    let extension_effort = kwargs
3798        .and_then(|values| values.get("reasoning_effort"))
3799        .filter(|value| !value.is_null())
3800        .map(|value| {
3801            serde_json::from_value::<ReasoningEffort>(value.clone()).map_err(|error| {
3802                Error::invalid_request(format!("chat_template_kwargs.reasoning_effort: {error}"))
3803            })
3804        })
3805        .transpose()?;
3806    if let (Some(standard), Some(extension)) = (request.reasoning_effort, extension_effort) {
3807        if standard != extension {
3808            return Err(Error::invalid_request(
3809                "reasoning_effort conflicts with chat_template_kwargs.reasoning_effort",
3810            ));
3811        }
3812    }
3813    options.reasoning_effort = request.reasoning_effort.or(extension_effort);
3814    let effort_thinking = request
3815        .reasoning_effort
3816        .map(|effort| effort != ReasoningEffort::None);
3817    if let (Some(explicit), Some(derived)) = (explicit_thinking, effort_thinking) {
3818        if explicit != derived {
3819            return Err(Error::invalid_request(
3820                "reasoning_effort conflicts with chat_template_kwargs.enable_thinking",
3821            ));
3822        }
3823    }
3824    // Standard effort overrides product defaults and supplies both variables.
3825    // Legacy-only kwargs keep their template-level behavior: an effort alone
3826    // must not change their thinking switch or acquire new capability checks.
3827    options.enable_thinking = explicit_thinking
3828        .or(effort_thinking)
3829        .or(default_enable_thinking);
3830    if let (Some(template), Some(effort)) = (model_template, request.reasoning_effort) {
3831        template.validate_reasoning_effort(effort)?;
3832    }
3833    Ok(options)
3834}
3835
3836fn render_messages_with_response_format_instruction(
3837    request: &ChatCompletionsRequest,
3838    output_contract: EffectiveChatOutputContract,
3839    reasoning_enabled: bool,
3840    model_template: Option<&ModelChatTemplate>,
3841    message_phases: Option<&[Option<AssistantMessagePhase>]>,
3842) -> (Vec<ChatMessage>, Vec<Option<AssistantMessagePhase>>) {
3843    let mut phases = message_phases
3844        .map(ToOwned::to_owned)
3845        .unwrap_or_else(|| vec![None; request.messages.len()]);
3846    debug_assert_eq!(phases.len(), request.messages.len());
3847    let Some(instruction) = response_format_prompt_instruction(
3848        request,
3849        output_contract,
3850        reasoning_enabled,
3851        model_template,
3852    ) else {
3853        return (request.messages.clone(), phases);
3854    };
3855    let mut messages = request.messages.clone();
3856    let leading_systems = messages
3857        .iter()
3858        .take_while(|message| message.role == MessageRole::System)
3859        .count();
3860    let mut system_parts = Vec::with_capacity(leading_systems + 1);
3861    system_parts.push(instruction);
3862    system_parts.extend(
3863        messages
3864            .drain(..leading_systems)
3865            .map(|message| message.content)
3866            .filter(|content| !content.is_empty()),
3867    );
3868    phases.drain(..leading_systems);
3869    messages.insert(
3870        0,
3871        ChatMessage {
3872            role: MessageRole::System,
3873            content: system_parts.join("\n\n"),
3874            reasoning: None,
3875            name: None,
3876            tool_calls: None,
3877            tool_call_id: None,
3878            function_call: None,
3879        },
3880    );
3881    phases.insert(0, None);
3882    (messages, phases)
3883}
3884
3885fn response_format_prompt_instruction(
3886    request: &ChatCompletionsRequest,
3887    output_contract: EffectiveChatOutputContract,
3888    reasoning_enabled: bool,
3889    model_template: Option<&ModelChatTemplate>,
3890) -> Option<String> {
3891    if !output_contract.accepts_requested_response_format() {
3892        return None;
3893    }
3894    let automatic_tools = request
3895        .tools
3896        .as_ref()
3897        .is_some_and(|tools| !tools.is_empty())
3898        && match request.tool_choice.as_ref() {
3899            None => true,
3900            Some(ToolChoice::Mode(mode)) => mode.eq_ignore_ascii_case("auto"),
3901            _ => false,
3902        }
3903        && matches!(
3904            output_contract,
3905            EffectiveChatOutputContract::StrictJsonSchemaContent
3906                | EffectiveChatOutputContract::JsonObjectContent
3907        );
3908    let native_tool_template =
3909        model_template.is_some_and(crate::chat_template::model_template_supports_tools);
3910    let tool_instruction = "If a tool is needed, put a JSON object with the declared function name in the name field and its argument object in the arguments field inside <tool_call>...</tool_call>. This envelope distinguishes a tool call from a final JSON answer.";
3911    if let Some(format) = request.response_format.as_ref() {
3912        return match format.format_type.as_str() {
3913            "json_object" if automatic_tools && native_tool_template => {
3914                Some(native_tool_final_format_instruction(r#"{"type":"object"}"#))
3915            }
3916            "json_object" if automatic_tools => Some(format!(
3917                "{tool_instruction} The response_format applies only to the final answer: output a single valid JSON object, with no markdown fences or extra text."
3918            )),
3919            "json_object" => Some(
3920                "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."
3921                    .to_string(),
3922            ),
3923            "json_schema" => {
3924                let schema = format.json_schema.as_ref()?.schema.as_ref()?;
3925                let schema_text = serde_json::to_string(schema).ok()?;
3926                Some(if automatic_tools && native_tool_template {
3927                    native_tool_final_format_instruction(&schema_text)
3928                } else if automatic_tools {
3929                    format!(
3930                        "{tool_instruction} Complete any enabled reasoning before the final answer. The response_format applies only to the final answer: output a single valid JSON value satisfying this JSON Schema, with no markdown fences or extra text. Schema: {schema_text}"
3931                    )
3932                } else if reasoning_enabled {
3933                    format!(
3934                        "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}"
3935                    )
3936                } else {
3937                    format!(
3938                        "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}"
3939                    )
3940                })
3941            }
3942            _ => None,
3943        };
3944    }
3945    None
3946}
3947
3948fn native_tool_final_format_instruction(schema: &str) -> String {
3949    // The model template owns tool selection and wire instructions. Give the
3950    // final-answer schema its own section so it is not folded into those tool
3951    // instructions, and leave the native call format unchanged.
3952    format!(
3953        "# Response Format\n\nYour final response should be a JSON value that conforms to the following schema:\n\n{schema}\n\nDo not wrap your JSON response in Markdown code blocks."
3954    )
3955}
3956
3957fn forced_tool_choice_response_format(
3958    request: &ChatCompletionsRequest,
3959) -> Option<ferrum_types::ResponseFormat> {
3960    let selected_tool = selected_tool_for_forced_tool_choice(request)?;
3961    let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
3962    serde_json::to_string(&schema)
3963        .ok()
3964        .map(ferrum_types::ResponseFormat::JsonSchema)
3965}
3966
3967fn requested_response_format_for_sampling(
3968    request: &ChatCompletionsRequest,
3969) -> ferrum_types::Result<Option<ferrum_types::ResponseFormat>> {
3970    let Some(format) = request.response_format.as_ref() else {
3971        return Ok(None);
3972    };
3973    match format.format_type.as_str() {
3974        "json_object" => Ok(Some(ferrum_types::ResponseFormat::JsonObject)),
3975        "json_schema" => {
3976            let Some(schema) = format.json_schema.as_ref() else {
3977                return Err(Error::invalid_request(
3978                    "response_format.json_schema.schema is required",
3979                ));
3980            };
3981            if !schema.strict.unwrap_or(false) {
3982                return Ok(None);
3983            }
3984            let Some(schema_value) = schema.schema.as_ref() else {
3985                return Err(Error::invalid_request(
3986                    "response_format.json_schema.schema is required",
3987                ));
3988            };
3989            serde_json::to_string(schema_value)
3990                .map(|schema| Some(ferrum_types::ResponseFormat::JsonSchema(schema)))
3991                .map_err(|err| Error::invalid_request(err.to_string()))
3992        }
3993        _ => Ok(None),
3994    }
3995}
3996
3997fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
3998    match request.tool_choice.as_ref()? {
3999        ToolChoice::Function {
4000            tool_type,
4001            function,
4002        } if tool_type == "function" => request
4003            .tools
4004            .as_ref()?
4005            .iter()
4006            .find(|tool| tool.function.name == function.name),
4007        ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
4008            single_function_tool(request.tools.as_deref()?)
4009        }
4010        _ => None,
4011    }
4012}
4013
4014fn guided_tool_arguments_schema(
4015    parameters: Option<&serde_json::Value>,
4016) -> Option<serde_json::Value> {
4017    let mut schema = parameters?.clone();
4018    bound_unconstrained_tool_argument_strings(
4019        &mut schema,
4020        DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
4021    );
4022    Some(schema)
4023}
4024
4025fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
4026    match value {
4027        serde_json::Value::Object(map) => {
4028            let is_string = map
4029                .get("type")
4030                .and_then(serde_json::Value::as_str)
4031                .is_some_and(|ty| ty == "string");
4032            let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
4033            if is_string && !has_finite_string_shape {
4034                map.insert(
4035                    "maxLength".to_string(),
4036                    serde_json::Value::Number(default_max.into()),
4037                );
4038            }
4039            if let Some(properties) = map
4040                .get_mut("properties")
4041                .and_then(serde_json::Value::as_object_mut)
4042            {
4043                for property in properties.values_mut() {
4044                    bound_unconstrained_tool_argument_strings(property, default_max);
4045                }
4046            }
4047            if let Some(items) = map.get_mut("items") {
4048                bound_unconstrained_tool_argument_strings(items, default_max);
4049            }
4050        }
4051        serde_json::Value::Array(items) => {
4052            for item in items {
4053                bound_unconstrained_tool_argument_strings(item, default_max);
4054            }
4055        }
4056        _ => {}
4057    }
4058}
4059
4060fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
4061    let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
4062    let tool = function_tools.next()?;
4063    function_tools.next().is_none().then_some(tool)
4064}
4065
4066fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
4067    if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
4068        let delta = text[*sent_len..].to_string();
4069        *sent_len = text.len();
4070        return delta;
4071    }
4072    *sent_len = text.len();
4073    String::new()
4074}
4075
4076fn project_typed_tool_response_content(
4077    response: &mut ferrum_types::ApiChatResponse,
4078    protocol: ModelOutputProtocol,
4079    started_in_think: bool,
4080) -> std::result::Result<(), ServerError> {
4081    if protocol == ModelOutputProtocol::HarmonyGptOss
4082        || response.message.tool_calls.is_empty()
4083        || response.message.content.is_empty()
4084    {
4085        return Ok(());
4086    }
4087    // Engine tool parsing can precede reasoning projection. Its structural
4088    // parser already removed the envelopes and owns every call and argument;
4089    // project only the outside text, never a payload or classified final JSON.
4090    response.message.content =
4091        parse_model_reasoning_response(protocol, &response.message.content, started_in_think)
4092            .map_err(|error| ServerError::InternalError(error.to_string()))?
4093            .content;
4094    Ok(())
4095}
4096
4097fn chat_api_response_from_parsed_generated_text(
4098    chat_request: &ferrum_types::ApiChatRequest,
4099    parsed: &ParsedReasoningResponse,
4100    finish_reason: FinishReason,
4101) -> Option<ferrum_types::ApiChatResponse> {
4102    parsed
4103        .reasoning
4104        .as_deref()
4105        .and_then(|reasoning| {
4106            ferrum_types::chat_api_response_from_generated_text(
4107                chat_request,
4108                reasoning,
4109                finish_reason,
4110            )
4111        })
4112        .map(|mut response| {
4113            // Reasoning is published through its own channel. Detecting a tool
4114            // there must not promote its surrounding text to visible content.
4115            response.message.content.clear();
4116            response
4117        })
4118        .or_else(|| {
4119            ferrum_types::chat_api_response_from_generated_text(
4120                chat_request,
4121                &parsed.content,
4122                finish_reason,
4123            )
4124        })
4125}
4126
4127fn finish_reason_allows_structured_api_response(finish_reason: FinishReason) -> bool {
4128    matches!(finish_reason, FinishReason::Stop | FinishReason::EOS)
4129}
4130
4131fn log_required_tool_choice_failure(
4132    request: &ChatCompletionsRequest,
4133    content: &str,
4134    reasoning: Option<&str>,
4135) {
4136    warn!(
4137        model = %request.model,
4138        content_len = content.len(),
4139        content_head = %log_excerpt(content, 512),
4140        reasoning_len = reasoning.map(str::len).unwrap_or(0),
4141        reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
4142        "model output did not satisfy required tool_choice"
4143    );
4144}
4145
4146fn log_excerpt(value: &str, max_chars: usize) -> String {
4147    let mut out = value.chars().take(max_chars).collect::<String>();
4148    if value.chars().count() > max_chars {
4149        out.push_str("...");
4150    }
4151    out
4152}
4153
4154fn normalize_structured_response_content(
4155    request: &ChatCompletionsRequest,
4156    content: &str,
4157) -> String {
4158    match EffectiveChatOutputContract::resolve(request) {
4159        EffectiveChatOutputContract::BestEffortJsonSchemaContent => {
4160            extract_json_object_text(content)
4161                .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
4162        }
4163        EffectiveChatOutputContract::RequiredToolCall
4164        | EffectiveChatOutputContract::StrictJsonSchemaContent
4165        | EffectiveChatOutputContract::JsonObjectContent
4166        | EffectiveChatOutputContract::Text => content.to_string(),
4167    }
4168}
4169
4170fn extract_json_object_text(text: &str) -> Option<String> {
4171    let text = strip_markdown_json_fence(text.trim());
4172    if serde_json::from_str::<serde_json::Value>(&text)
4173        .ok()
4174        .filter(|value| value.is_object())
4175        .is_some()
4176    {
4177        return Some(text.to_string());
4178    }
4179
4180    let start = text.find('{')?;
4181    let mut depth = 0usize;
4182    let mut in_string = false;
4183    let mut escaped = false;
4184    for (offset, ch) in text[start..].char_indices() {
4185        if in_string {
4186            if escaped {
4187                escaped = false;
4188            } else if ch == '\\' {
4189                escaped = true;
4190            } else if ch == '"' {
4191                in_string = false;
4192            }
4193            continue;
4194        }
4195        match ch {
4196            '"' => in_string = true,
4197            '{' => depth += 1,
4198            '}' => {
4199                depth = depth.saturating_sub(1);
4200                if depth == 0 {
4201                    let end = start + offset + ch.len_utf8();
4202                    let candidate = &text[start..end];
4203                    if serde_json::from_str::<serde_json::Value>(candidate)
4204                        .ok()
4205                        .filter(|value| value.is_object())
4206                        .is_some()
4207                    {
4208                        return Some(candidate.to_string());
4209                    }
4210                }
4211            }
4212            _ => {}
4213        }
4214    }
4215    None
4216}
4217
4218fn api_chat_request(
4219    request: &ChatCompletionsRequest,
4220    effective_tool_choice: Option<&ToolChoice>,
4221    tool_call_protocol: ferrum_types::ApiToolCallProtocol,
4222) -> ferrum_types::ApiChatRequest {
4223    ferrum_types::ApiChatRequest {
4224        messages: request.messages.iter().map(api_chat_message).collect(),
4225        tools: request
4226            .tools
4227            .as_deref()
4228            .unwrap_or_default()
4229            .iter()
4230            .map(api_tool)
4231            .collect(),
4232        tool_choice: effective_tool_choice.map(api_tool_choice),
4233        tool_call_protocol,
4234        legacy_functions: request
4235            .functions
4236            .as_deref()
4237            .unwrap_or_default()
4238            .iter()
4239            .map(api_function)
4240            .collect(),
4241        legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
4242        response_format: request.response_format.as_ref().map(api_response_format),
4243        stream_options: request.stream_options.as_ref().map(|opts| {
4244            ferrum_types::ApiStreamOptions {
4245                include_usage: opts.include_usage,
4246            }
4247        }),
4248    }
4249}
4250
4251fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
4252    ferrum_types::ApiChatMessage {
4253        role: match message.role {
4254            MessageRole::System => ferrum_types::ApiMessageRole::System,
4255            MessageRole::User => ferrum_types::ApiMessageRole::User,
4256            MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
4257            MessageRole::Function => ferrum_types::ApiMessageRole::Function,
4258            MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
4259        },
4260        content: message.content.clone(),
4261        name: message.name.clone(),
4262        tool_calls: message
4263            .tool_calls
4264            .as_deref()
4265            .unwrap_or_default()
4266            .iter()
4267            .map(api_tool_call)
4268            .collect(),
4269        tool_call_id: message.tool_call_id.clone(),
4270        function_call: message.function_call.as_ref().map(api_function_call),
4271    }
4272}
4273
4274fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
4275    ferrum_types::ApiTool {
4276        tool_type: tool.tool_type.clone(),
4277        function: api_function(&tool.function),
4278    }
4279}
4280
4281fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
4282    ferrum_types::ApiFunction {
4283        name: function.name.clone(),
4284        description: function.description.clone(),
4285        parameters: function.parameters.clone(),
4286        strict: function.strict,
4287    }
4288}
4289
4290fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
4291    match choice {
4292        ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
4293        ToolChoice::Function {
4294            tool_type,
4295            function,
4296        } => ferrum_types::ApiToolChoice::Function {
4297            tool_type: tool_type.clone(),
4298            function: ferrum_types::ApiToolChoiceFunction {
4299                name: function.name.clone(),
4300            },
4301        },
4302    }
4303}
4304
4305fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
4306    match choice {
4307        FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
4308        FunctionCallChoice::Function { name } => {
4309            ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
4310        }
4311    }
4312}
4313
4314fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
4315    ferrum_types::ApiToolCall {
4316        id: tool_call.id.clone(),
4317        tool_type: tool_call.tool_type.clone(),
4318        function: api_function_call(&tool_call.function),
4319    }
4320}
4321
4322fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
4323    ferrum_types::ApiFunctionCall {
4324        name: function_call.name.clone(),
4325        arguments: function_call.arguments.clone(),
4326    }
4327}
4328
4329fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4330    ChatMessage {
4331        role: openai_message_role_from_api(message.role),
4332        content: message.content.clone(),
4333        reasoning: None,
4334        name: message.name.clone(),
4335        tool_calls: if message.tool_calls.is_empty() {
4336            None
4337        } else {
4338            Some(
4339                message
4340                    .tool_calls
4341                    .iter()
4342                    .map(openai_tool_call_from_api)
4343                    .collect(),
4344            )
4345        },
4346        tool_call_id: message.tool_call_id.clone(),
4347        function_call: message
4348            .function_call
4349            .as_ref()
4350            .map(openai_function_call_from_api),
4351    }
4352}
4353
4354fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
4355    match role {
4356        ferrum_types::ApiMessageRole::System => MessageRole::System,
4357        ferrum_types::ApiMessageRole::User => MessageRole::User,
4358        ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
4359        ferrum_types::ApiMessageRole::Function => MessageRole::Function,
4360        ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
4361    }
4362}
4363
4364fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
4365    ChatToolCall {
4366        index: None,
4367        id: tool_call.id.clone(),
4368        tool_type: tool_call.tool_type.clone(),
4369        function: openai_function_call_from_api(&tool_call.function),
4370    }
4371}
4372
4373fn openai_tool_call_delta_from_api(
4374    index: usize,
4375    tool_call: &ferrum_types::ApiToolCall,
4376) -> ChatToolCall {
4377    ChatToolCall {
4378        index: Some(usize_to_u32_saturating(index)),
4379        id: tool_call.id.clone(),
4380        tool_type: tool_call.tool_type.clone(),
4381        function: openai_function_call_from_api(&tool_call.function),
4382    }
4383}
4384
4385fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4386    let mut delta = openai_chat_message_from_api(message);
4387    if !message.tool_calls.is_empty() {
4388        delta.tool_calls = Some(
4389            message
4390                .tool_calls
4391                .iter()
4392                .enumerate()
4393                .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
4394                .collect(),
4395        );
4396    }
4397    delta
4398}
4399
4400fn openai_function_call_from_api(
4401    function_call: &ferrum_types::ApiFunctionCall,
4402) -> ChatFunctionCall {
4403    ChatFunctionCall {
4404        name: function_call.name.clone(),
4405        arguments: function_call.arguments.clone(),
4406    }
4407}
4408
4409fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
4410    ferrum_types::ApiResponseFormat {
4411        format_type: format.format_type.clone(),
4412        json_schema: format
4413            .json_schema
4414            .as_ref()
4415            .map(|schema| ferrum_types::ApiJsonSchema {
4416                name: schema.name.clone(),
4417                schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
4418                strict: schema.strict,
4419            }),
4420    }
4421}
4422
4423fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
4424    if request.messages.is_empty() {
4425        return Err(ServerError::invalid_request(
4426            "messages array must not be empty",
4427            Some("messages"),
4428        ));
4429    }
4430
4431    if let Some(n) = request.n {
4432        if n != 1 {
4433            return Err(ServerError::unsupported_feature(
4434                "only n=1 is supported for chat completions",
4435                Some("n"),
4436            ));
4437        }
4438    }
4439
4440    if request
4441        .logit_bias
4442        .as_ref()
4443        .is_some_and(|bias| !bias.is_empty())
4444    {
4445        return Err(ServerError::unsupported_feature(
4446            "logit_bias is not supported",
4447            Some("logit_bias"),
4448        ));
4449    }
4450    if request.logprobs.unwrap_or(false) {
4451        return Err(ServerError::unsupported_feature(
4452            "logprobs is not supported",
4453            Some("logprobs"),
4454        ));
4455    }
4456    if request.top_logprobs.unwrap_or(0) > 0 {
4457        return Err(ServerError::unsupported_feature(
4458            "top_logprobs is not supported",
4459            Some("top_logprobs"),
4460        ));
4461    }
4462
4463    if let Some(top_k) = request.top_k {
4464        if top_k < -1 {
4465            return Err(ServerError::invalid_request(
4466                "top_k must be -1, 0, or a positive integer",
4467                Some("top_k"),
4468            ));
4469        }
4470    }
4471    if let Some(min_p) = request.min_p {
4472        if !min_p.is_finite() || !(0.0..=1.0).contains(&min_p) {
4473            return Err(ServerError::invalid_request(
4474                "min_p must be in range [0, 1]",
4475                Some("min_p"),
4476            ));
4477        }
4478    }
4479    if let Some(repetition_penalty) = request.repetition_penalty {
4480        if !repetition_penalty.is_finite() || repetition_penalty <= 0.0 {
4481            return Err(ServerError::invalid_request(
4482                "repetition_penalty must be positive",
4483                Some("repetition_penalty"),
4484            ));
4485        }
4486    }
4487    if let Some(presence_penalty) = request.presence_penalty {
4488        if !presence_penalty.is_finite() || !(-2.0..=2.0).contains(&presence_penalty) {
4489            return Err(ServerError::invalid_request(
4490                "presence_penalty must be in range [-2, 2]",
4491                Some("presence_penalty"),
4492            ));
4493        }
4494    }
4495    if let Some(frequency_penalty) = request.frequency_penalty {
4496        if !frequency_penalty.is_finite() || !(-2.0..=2.0).contains(&frequency_penalty) {
4497            return Err(ServerError::invalid_request(
4498                "frequency_penalty must be in range [-2, 2]",
4499                Some("frequency_penalty"),
4500            ));
4501        }
4502    }
4503
4504    if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
4505        return Err(ServerError::invalid_request(
4506            "stream_options is only valid when stream=true",
4507            Some("stream_options"),
4508        ));
4509    }
4510    ensure_response_format_supported(request)?;
4511
4512    if let Some(tools) = &request.tools {
4513        for tool in tools {
4514            if tool.tool_type != "function" {
4515                return Err(ServerError::unsupported_feature(
4516                    "only function tools are supported",
4517                    Some("tools"),
4518                ));
4519            }
4520        }
4521    }
4522
4523    if let Some(choice) = &request.tool_choice {
4524        match choice {
4525            ToolChoice::Mode(mode)
4526                if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4527            ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
4528                if request.tools.as_deref().unwrap_or_default().is_empty() {
4529                    return Err(ServerError::invalid_request(
4530                        "tool_choice=required requires at least one function tool",
4531                        Some("tool_choice"),
4532                    ));
4533                }
4534            }
4535            ToolChoice::Mode(_) => {
4536                return Err(ServerError::unsupported_feature(
4537                    "unsupported tool_choice mode",
4538                    Some("tool_choice"),
4539                ));
4540            }
4541            ToolChoice::Function {
4542                tool_type,
4543                function,
4544            } => {
4545                if tool_type != "function" {
4546                    return Err(ServerError::unsupported_feature(
4547                        "only function tool_choice is supported",
4548                        Some("tool_choice"),
4549                    ));
4550                }
4551                let declared = request
4552                    .tools
4553                    .as_deref()
4554                    .unwrap_or_default()
4555                    .iter()
4556                    .any(|tool| tool.function.name == function.name);
4557                if !declared {
4558                    return Err(ServerError::invalid_request(
4559                        "tool_choice selects a function that is not declared in tools",
4560                        Some("tool_choice"),
4561                    ));
4562                }
4563            }
4564        }
4565    }
4566
4567    if let Some(choice) = &request.function_call {
4568        match choice {
4569            FunctionCallChoice::Mode(mode)
4570                if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4571            FunctionCallChoice::Mode(_) => {
4572                return Err(ServerError::unsupported_feature(
4573                    "unsupported function_call mode",
4574                    Some("function_call"),
4575                ));
4576            }
4577            FunctionCallChoice::Function { name } => {
4578                let declared = request
4579                    .functions
4580                    .as_deref()
4581                    .unwrap_or_default()
4582                    .iter()
4583                    .any(|function| function.name == *name);
4584                if !declared {
4585                    return Err(ServerError::invalid_request(
4586                        "function_call selects a function that is not declared in functions",
4587                        Some("function_call"),
4588                    ));
4589                }
4590            }
4591        }
4592    }
4593
4594    Ok(())
4595}
4596
4597fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
4598    match request.tool_choice.as_ref() {
4599        Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
4600        Some(ToolChoice::Function {
4601            tool_type,
4602            function,
4603        }) => {
4604            tool_type == "function"
4605                && request
4606                    .tools
4607                    .as_deref()
4608                    .unwrap_or_default()
4609                    .iter()
4610                    .any(|tool| tool.function.name == function.name)
4611        }
4612        _ => false,
4613    }
4614}
4615
4616fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
4617    let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
4618    let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
4619    let total_tokens = usize_to_u32_saturating(usage.total_tokens);
4620    Usage {
4621        prompt_tokens,
4622        completion_tokens,
4623        total_tokens,
4624    }
4625}
4626
4627fn usize_to_u32_saturating(value: usize) -> u32 {
4628    u32::try_from(value).unwrap_or(u32::MAX)
4629}
4630
4631fn ensure_response_format_supported(
4632    request: &ChatCompletionsRequest,
4633) -> std::result::Result<(), ServerError> {
4634    if let Some(rf) = &request.response_format {
4635        match rf.format_type.as_str() {
4636            "text" | "json_object" => {}
4637            "json_schema" => {
4638                let Some(schema_config) = rf.json_schema.as_ref() else {
4639                    return Err(ServerError::invalid_request(
4640                        "response_format.json_schema.schema is required",
4641                        Some("response_format.json_schema"),
4642                    ));
4643                };
4644                let Some(schema) = schema_config.schema.as_ref() else {
4645                    return Err(ServerError::invalid_request(
4646                        "response_format.json_schema.schema is required",
4647                        Some("response_format.json_schema"),
4648                    ));
4649                };
4650                if schema_config.strict.unwrap_or(false) {
4651                    compiled_json_schema_validator(schema).map_err(|reason| {
4652                        ServerError::invalid_request(
4653                            format!("unsupported strict json_schema: {reason}"),
4654                            Some("response_format.json_schema"),
4655                        )
4656                    })?;
4657                }
4658            }
4659            _ => {
4660                return Err(ServerError::invalid_request(
4661                    "unsupported response_format.type",
4662                    Some("response_format.type"),
4663                ));
4664            }
4665        }
4666    }
4667    Ok(())
4668}
4669
4670fn strict_json_schema_string(
4671    request: &ChatCompletionsRequest,
4672) -> std::result::Result<Option<String>, ServerError> {
4673    let Some(rf) = &request.response_format else {
4674        return Ok(None);
4675    };
4676    if rf.format_type != "json_schema" {
4677        return Ok(None);
4678    }
4679    let Some(schema) = &rf.json_schema else {
4680        return Err(ServerError::invalid_request(
4681            "response_format.json_schema.schema is required",
4682            Some("response_format.json_schema"),
4683        ));
4684    };
4685    let Some(schema_value) = schema.schema.as_ref() else {
4686        return Err(ServerError::invalid_request(
4687            "response_format.json_schema.schema is required",
4688            Some("response_format.json_schema"),
4689        ));
4690    };
4691    if !schema.strict.unwrap_or(false) {
4692        return Ok(None);
4693    }
4694    serde_json::to_string(schema_value).map(Some).map_err(|e| {
4695        ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
4696    })
4697}
4698
4699fn validate_hard_structured_response(
4700    request: &ChatCompletionsRequest,
4701    content: &str,
4702    validated_chat_response: Option<&ferrum_types::ApiChatResponse>,
4703) -> std::result::Result<(), ServerError> {
4704    // Both terminal paths validate tool names, arguments and choice before
4705    // reaching this point. The final-answer schema applies to the content
4706    // branch; a tool call has its own independently validated argument schema.
4707    if validated_chat_response.is_some_and(|response| !response.message.tool_calls.is_empty()) {
4708        return Ok(());
4709    }
4710    // Validate the same content that the typed response will publish. A text
4711    // fallback may interpret marker-like strings inside valid JSON as framing.
4712    let content = validated_chat_response
4713        .map(|response| response.message.content.as_str())
4714        .unwrap_or(content);
4715    match EffectiveChatOutputContract::resolve(request) {
4716        EffectiveChatOutputContract::JsonObjectContent => {
4717            let value = serde_json::from_str::<serde_json::Value>(content).map_err(|error| {
4718                ServerError::InternalError(format!(
4719                    "model output did not satisfy response_format.json_object: invalid JSON: {error}"
4720                ))
4721            })?;
4722            if !value.is_object() {
4723                return Err(ServerError::InternalError(
4724                    "model output did not satisfy response_format.json_object: root must be an object"
4725                        .to_string(),
4726                ));
4727            }
4728            Ok(())
4729        }
4730        EffectiveChatOutputContract::StrictJsonSchemaContent => {
4731            let Some(schema_json) = strict_json_schema_string(request)? else {
4732                return Ok(());
4733            };
4734            let schema: serde_json::Value = serde_json::from_str(&schema_json).map_err(|e| {
4735                ServerError::InternalError(format!(
4736                    "strict json_schema could not be reconstructed after request validation: {e}"
4737                ))
4738            })?;
4739            validate_json_text_against_schema(&schema, content).map_err(|reason| {
4740                ServerError::InternalError(format!(
4741                    "model output did not satisfy response_format.json_schema.strict: {reason}"
4742                ))
4743            })
4744        }
4745        EffectiveChatOutputContract::RequiredToolCall
4746        | EffectiveChatOutputContract::BestEffortJsonSchemaContent
4747        | EffectiveChatOutputContract::Text => Ok(()),
4748    }
4749}
4750
4751fn structured_response_error_param(contract: EffectiveChatOutputContract) -> Option<&'static str> {
4752    match contract {
4753        EffectiveChatOutputContract::JsonObjectContent => Some("response_format"),
4754        EffectiveChatOutputContract::StrictJsonSchemaContent => Some("response_format.json_schema"),
4755        _ => None,
4756    }
4757}
4758
4759fn validate_structured_tool_response(
4760    request: &ChatCompletionsRequest,
4761    response: &ferrum_types::ApiChatResponse,
4762) -> std::result::Result<(), ServerError> {
4763    let required = tool_choice_required(request);
4764    if response.message.tool_calls.is_empty() {
4765        if required {
4766            return Err(ServerError::invalid_request(
4767                "model output did not satisfy required tool_choice",
4768                Some("tool_choice"),
4769            ));
4770        }
4771        return Ok(());
4772    }
4773
4774    if tool_choice_none(request.tool_choice.as_ref()) {
4775        return Err(ServerError::InternalError(
4776            "model emitted a tool call while tool_choice is 'none'".to_string(),
4777        ));
4778    }
4779
4780    if required {
4781        if !response.message.content.trim().is_empty() {
4782            return Err(ServerError::InternalError(
4783                "required tool response contained assistant content".to_string(),
4784            ));
4785        }
4786        if response.finish_reason.as_deref() != Some("tool_calls") {
4787            return Err(ServerError::InternalError(
4788                "required tool response did not finish with tool_calls".to_string(),
4789            ));
4790        }
4791    }
4792
4793    let tools = request.tools.as_deref().unwrap_or_default();
4794    for call in &response.message.tool_calls {
4795        if call.tool_type != "function" {
4796            return Err(ServerError::InternalError(format!(
4797                "model emitted unsupported tool call type '{}'",
4798                call.tool_type
4799            )));
4800        }
4801        let Some(tool) = tools
4802            .iter()
4803            .find(|tool| tool.tool_type == "function" && tool.function.name == call.function.name)
4804        else {
4805            return Err(ServerError::InternalError(format!(
4806                "model emitted undeclared tool call '{}'",
4807                call.function.name
4808            )));
4809        };
4810        if let Some(ToolChoice::Function {
4811            tool_type,
4812            function,
4813        }) = request.tool_choice.as_ref()
4814        {
4815            if tool_type != "function" || function.name != call.function.name {
4816                return Err(ServerError::InternalError(format!(
4817                    "model emitted tool '{}' instead of selected tool '{}'",
4818                    call.function.name, function.name
4819                )));
4820            }
4821        }
4822
4823        let arguments: serde_json::Value =
4824            serde_json::from_str(&call.function.arguments).map_err(|e| {
4825                ServerError::InternalError(format!(
4826                    "model emitted invalid JSON arguments for tool '{}': {e}",
4827                    call.function.name
4828                ))
4829            })?;
4830        if !arguments.is_object() {
4831            return Err(ServerError::InternalError(format!(
4832                "model emitted non-object arguments for tool '{}'",
4833                call.function.name
4834            )));
4835        }
4836        // Automatic Chat Completions tools are best effort unless the tool
4837        // opts into strict mode. Keep non-strict arguments intact so clients
4838        // can report validation errors through the normal tool-result turn.
4839        // Required/forced calls retain their existing hard output contract.
4840        if let Some(schema) = tool
4841            .function
4842            .parameters
4843            .as_ref()
4844            .filter(|_| required || tool.function.strict.unwrap_or(false))
4845        {
4846            validate_json_text_against_schema(schema, &call.function.arguments).map_err(
4847                |reason| {
4848                    ServerError::InternalError(format!(
4849                        "model arguments for tool '{}' did not satisfy its schema: {reason}",
4850                        call.function.name
4851                    ))
4852                },
4853            )?;
4854        }
4855    }
4856    Ok(())
4857}
4858
4859fn validate_json_text_against_schema(
4860    schema: &serde_json::Value,
4861    content: &str,
4862) -> std::result::Result<(), String> {
4863    let value = serde_json::from_str::<serde_json::Value>(content)
4864        .map_err(|e| format!("invalid JSON: {e}"))?;
4865    compiled_json_schema_validator(schema)?
4866        .validate(&value)
4867        .map_err(|error| error.to_string())
4868}
4869
4870fn compiled_json_schema_validator(
4871    schema: &serde_json::Value,
4872) -> std::result::Result<Arc<jsonschema::Validator>, String> {
4873    let cache_key = serde_json::to_string(schema)
4874        .map_err(|error| format!("could not serialize JSON Schema: {error}"))?;
4875    let cache = JSON_SCHEMA_VALIDATOR_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
4876    let mut validators = cache
4877        .lock()
4878        .map_err(|_| "JSON Schema validator cache lock was poisoned".to_string())?;
4879    if let Some(validator) = validators.get(&cache_key) {
4880        return Ok(Arc::clone(validator));
4881    }
4882
4883    let validator = Arc::new(
4884        jsonschema::validator_for(schema)
4885            .map_err(|error| format!("could not compile JSON Schema: {error}"))?,
4886    );
4887    if validators.len() >= MAX_CACHED_JSON_SCHEMA_VALIDATORS {
4888        validators.clear();
4889    }
4890    validators.insert(cache_key, Arc::clone(&validator));
4891    Ok(validator)
4892}
4893
4894fn stream_validation_error_message(error: ServerError) -> String {
4895    match error {
4896        ServerError::InternalError(message)
4897        | ServerError::NotImplemented(message)
4898        | ServerError::ServiceUnavailable(message)
4899        | ServerError::ContextLengthExceeded(message)
4900        | ServerError::InvalidRequest { message, .. }
4901        | ServerError::UnsupportedFeature { message, .. } => message,
4902    }
4903}
4904
4905fn server_error_from_ferrum_error(error: Error) -> ServerError {
4906    match error {
4907        Error::RequestValidation { message } => ServerError::invalid_request(message, None),
4908        error @ Error::ContextLengthExceeded { .. } => {
4909            ServerError::ContextLengthExceeded(error.to_string())
4910        }
4911        Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
4912        other => ServerError::InternalError(other.to_string()),
4913    }
4914}
4915
4916fn stream_error_payload(
4917    message: impl Into<String>,
4918    error_type: &str,
4919    param: Option<&str>,
4920) -> OpenAiError {
4921    OpenAiError {
4922        error: OpenAiErrorDetail {
4923            message: message.into(),
4924            error_type: error_type.to_string(),
4925            param: param.map(str::to_string),
4926            code: None,
4927        },
4928    }
4929}
4930
4931fn openai_error_sse_event(
4932    message: impl Into<String>,
4933    error_type: &str,
4934    param: Option<&str>,
4935) -> Event {
4936    Event::default()
4937        .json_data(&stream_error_payload(message, error_type, param))
4938        .unwrap_or_else(|_| Event::default().data("error"))
4939}
4940
4941fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
4942    let prompt = request
4943        .prompt
4944        .as_text()
4945        .expect("completion prompt validated before conversion");
4946    InferenceRequest {
4947        id: RequestId(Uuid::new_v4()),
4948        model_id: ModelId(request.model.clone()),
4949        prompt: prompt.to_string(),
4950        sampling_params: SamplingParams {
4951            max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
4952            temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
4953            top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
4954            top_k: None,
4955            repetition_penalty: 1.0,
4956            presence_penalty: 0.0,
4957            frequency_penalty: 0.0,
4958            stop_sequences: request.stop.clone().unwrap_or_default(),
4959            seed: None,
4960            min_p: None,
4961            tfs: None,
4962            typical_p: None,
4963            mirostat: None,
4964            response_format: ferrum_types::ResponseFormat::Text,
4965            structured_output_start: StructuredOutputStart::Immediate,
4966            response_completion_boundary: ResponseCompletionBoundary::Immediate,
4967            model_output_protocol: ferrum_types::ModelOutputProtocol::Text,
4968        },
4969        stream: request.stream.unwrap_or(false),
4970        priority: Priority::Normal,
4971        client_id: None,
4972        session_id: None,
4973        created_at: chrono::Utc::now(),
4974        api_request: Some(ferrum_types::ApiRequest::Completion(
4975            ferrum_types::ApiCompletionRequest {
4976                prompt: prompt.to_string(),
4977                response_format: None,
4978            },
4979        )),
4980        evidence_request: Default::default(),
4981        metadata: if request.max_tokens.is_none() {
4982            HashMap::from([(
4983                DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
4984                serde_json::json!(true),
4985            )])
4986        } else {
4987            HashMap::new()
4988        },
4989    }
4990}
4991
4992fn resolve_request_model<'a>(
4993    registry: &'a ServedModelRegistry,
4994    request_model: &str,
4995    required_kind: ServedModelKind,
4996) -> std::result::Result<(ModelId, Option<&'a LoraAdapterModel>), ServerError> {
4997    if registry.is_empty() {
4998        return Ok((ModelId::new(request_model), None));
4999    }
5000    let entry = registry
5001        .resolve(request_model, required_kind)
5002        .ok_or_else(|| {
5003            ServerError::invalid_request(format!("unknown model: {request_model}"), Some("model"))
5004        })?;
5005    Ok((entry.engine_model_id().clone(), entry.adapter()))
5006}
5007
5008fn apply_served_model_resolution(
5009    inference_request: &mut InferenceRequest,
5010    engine_model_id: ModelId,
5011    adapter: Option<&LoraAdapterModel>,
5012) {
5013    inference_request.model_id = engine_model_id;
5014    if let Some(adapter) = adapter {
5015        inference_request.metadata.insert(
5016            "ferrum_lora_adapter".to_string(),
5017            serde_json::json!(adapter.name),
5018        );
5019        inference_request.metadata.insert(
5020            "ferrum_lora_model_id".to_string(),
5021            serde_json::json!(adapter.model_id),
5022        );
5023        inference_request.metadata.insert(
5024            "ferrum_lora_path".to_string(),
5025            serde_json::json!(adapter.path),
5026        );
5027    }
5028}
5029
5030async fn handle_completions_sync(
5031    state: AppState,
5032    openai_request: CompletionsRequest,
5033    inference_request: InferenceRequest,
5034) -> std::result::Result<Response, ServerError> {
5035    let engine = state.llm.clone().ok_or_else(|| {
5036        ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
5037    })?;
5038    match engine.infer(inference_request).await {
5039        Ok(output) => {
5040            let InferenceResponse {
5041                text: output_text,
5042                finish_reason,
5043                usage,
5044                api_response,
5045                ..
5046            } = output;
5047            let stop_sequences = openai_request.stop.clone().unwrap_or_default();
5048            let mut text = strip_after_stop(&output_text, &stop_sequences);
5049            let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
5050            if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
5051                api_response.as_ref()
5052            {
5053                text = strip_after_stop(&completion_response.text, &stop_sequences);
5054                if let Some(reason) = &completion_response.finish_reason {
5055                    openai_finish_reason = reason.clone();
5056                }
5057            }
5058            let response = CompletionsResponse {
5059                id: Uuid::new_v4().to_string(),
5060                object: "text_completion".to_string(),
5061                created: chrono::Utc::now().timestamp() as u64,
5062                model: openai_request.model,
5063                choices: vec![CompletionChoice {
5064                    text,
5065                    index: 0,
5066                    finish_reason: Some(openai_finish_reason),
5067                }],
5068                usage: Some(openai_usage_from_token_usage(&usage)),
5069            };
5070            Ok(Json(response).into_response())
5071        }
5072        Err(e) => {
5073            error!("Completion generation failed: {}", e);
5074            Err(server_error_from_ferrum_error(e))
5075        }
5076    }
5077}
5078
5079async fn handle_completions_stream(
5080    state: AppState,
5081    openai_request: CompletionsRequest,
5082    inference_request: InferenceRequest,
5083) -> std::result::Result<Response, ServerError> {
5084    let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
5085    let engine = state.llm.clone().ok_or_else(|| {
5086        ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
5087    })?;
5088    let request_id = Uuid::new_v4().to_string();
5089
5090    // Resolve startup rejection before sending SSE headers, as on the Chat
5091    // route. Once a stream exists, later failures remain SSE error events.
5092    let mut stream = engine.infer_stream(inference_request).await.map_err(|e| {
5093        error!("Failed to start completion stream: {}", e);
5094        server_error_from_ferrum_error(e)
5095    })?;
5096    tokio::spawn(async move {
5097        while let Some(result) = stream.next().await {
5098            match result {
5099                Ok(chunk) => {
5100                    let response_chunk = CompletionsResponse {
5101                        id: request_id.clone(),
5102                        object: "text_completion".to_string(),
5103                        created: chrono::Utc::now().timestamp() as u64,
5104                        model: openai_request.model.clone(),
5105                        choices: vec![CompletionChoice {
5106                            text: chunk.text.clone(),
5107                            index: 0,
5108                            finish_reason: chunk
5109                                .finish_reason
5110                                .as_ref()
5111                                .map(finish_reason_to_string),
5112                        }],
5113                        usage: None,
5114                    };
5115                    let event = Event::default()
5116                        .json_data(&response_chunk)
5117                        .unwrap_or_else(|_| Event::default().data("error"));
5118                    if tx.send(Ok(event)).is_err() {
5119                        break;
5120                    }
5121                    if chunk.finish_reason.is_some() {
5122                        if let Some(usage) = chunk.usage.as_ref().map(openai_usage_from_token_usage)
5123                        {
5124                            let final_chunk = CompletionsResponse {
5125                                id: request_id.clone(),
5126                                object: "text_completion".to_string(),
5127                                created: chrono::Utc::now().timestamp() as u64,
5128                                model: openai_request.model.clone(),
5129                                choices: vec![],
5130                                usage: Some(usage),
5131                            };
5132                            let event = Event::default()
5133                                .json_data(&final_chunk)
5134                                .unwrap_or_else(|_| Event::default().data("error"));
5135                            let _ = tx.send(Ok(event));
5136                        }
5137                        let _ = tx.send(Ok(Event::default().data("[DONE]")));
5138                        break;
5139                    }
5140                }
5141                Err(e) => {
5142                    error!("Completion stream generation error: {}", e);
5143                    let _ = tx.send(Ok(openai_error_sse_event(
5144                        e.to_string(),
5145                        "internal_server_error",
5146                        None,
5147                    )));
5148                    let _ = tx.send(Ok(Event::default().data("[DONE]")));
5149                    break;
5150                }
5151            }
5152        }
5153    });
5154
5155    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
5156    Ok(Sse::new(stream).into_response())
5157}
5158
5159/// Other handlers
5160async fn completions_handler(
5161    State(state): State<AppState>,
5162    request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
5163) -> std::result::Result<Response, ServerError> {
5164    let Json(request) = request.map_err(|e| {
5165        ServerError::invalid_request(format!("invalid completions request: {e}"), None)
5166    })?;
5167    validate_completion_request(&request)?;
5168    let (engine_model_id, lora_adapter) = resolve_request_model(
5169        &state.served_model_registry,
5170        &request.model,
5171        ServedModelKind::Llm,
5172    )?;
5173    let mut inference_request = convert_completion_request(&request);
5174    apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
5175    if request.stream.unwrap_or(false) {
5176        handle_completions_stream(state, request, inference_request).await
5177    } else {
5178        handle_completions_sync(state, request, inference_request).await
5179    }
5180}
5181
5182fn validate_completion_request(
5183    request: &CompletionsRequest,
5184) -> std::result::Result<(), ServerError> {
5185    if request.prompt.as_text().is_none() {
5186        return Err(ServerError::invalid_request(
5187            "only string prompt is supported for completions",
5188            Some("prompt"),
5189        ));
5190    }
5191    if let Some(n) = request.n {
5192        if n != 1 {
5193            return Err(ServerError::unsupported_feature(
5194                "only n=1 is supported for completions",
5195                Some("n"),
5196            ));
5197        }
5198    }
5199    if request.logprobs.is_some() {
5200        return Err(ServerError::unsupported_feature(
5201            "logprobs is not supported for completions",
5202            Some("logprobs"),
5203        ));
5204    }
5205    if request
5206        .logit_bias
5207        .as_ref()
5208        .is_some_and(|bias| !bias.is_empty())
5209    {
5210        return Err(ServerError::unsupported_feature(
5211            "logit_bias is not supported",
5212            Some("logit_bias"),
5213        ));
5214    }
5215    Ok(())
5216}
5217
5218/// Embeddings handler — text and image embedding via OpenAI-compatible API.
5219async fn embeddings_handler(
5220    State(state): State<AppState>,
5221    request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
5222) -> std::result::Result<Response, ServerError> {
5223    let Json(request) = request.map_err(|e| {
5224        ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
5225    })?;
5226
5227    let span = span!(Level::INFO, "embeddings", model = %request.model);
5228    let _enter = span.enter();
5229
5230    validate_embeddings_request(&request)?;
5231    resolve_request_model(
5232        &state.served_model_registry,
5233        &request.model,
5234        ServedModelKind::Embedding,
5235    )?;
5236
5237    // Flatten input into individual items
5238    let items: Vec<EmbeddingItem> = match request.input {
5239        EmbeddingInput::Single(text) => vec![EmbeddingItem {
5240            text: Some(text),
5241            image: None,
5242        }],
5243        EmbeddingInput::Batch(texts) => texts
5244            .into_iter()
5245            .map(|t| EmbeddingItem {
5246                text: Some(t),
5247                image: None,
5248            })
5249            .collect(),
5250        EmbeddingInput::SingleObject(item) => vec![item],
5251        EmbeddingInput::BatchObjects(items) => items,
5252    };
5253
5254    if items.is_empty() {
5255        return Err(ServerError::invalid_request(
5256            "input must not be empty",
5257            Some("input"),
5258        ));
5259    }
5260
5261    let mut data = Vec::with_capacity(items.len());
5262    let mut total_tokens = 0u32;
5263
5264    let engine = state.embed.as_ref().ok_or_else(|| {
5265        ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
5266    })?;
5267    for (idx, item) in items.iter().enumerate() {
5268        let embedding = if let Some(ref image) = item.image {
5269            engine
5270                .embed_image(image)
5271                .await
5272                .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
5273        } else if let Some(ref text) = item.text {
5274            total_tokens += text.len() as u32;
5275            engine
5276                .embed_text(text)
5277                .await
5278                .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
5279        } else {
5280            return Err(ServerError::invalid_request(
5281                "each input item must have either text or image",
5282                Some("input"),
5283            ));
5284        };
5285
5286        data.push(EmbeddingData {
5287            object: "embedding".to_string(),
5288            embedding,
5289            index: idx,
5290        });
5291    }
5292
5293    let response = EmbeddingsResponse {
5294        object: "list".to_string(),
5295        data,
5296        model: request.model,
5297        usage: EmbeddingUsage {
5298            prompt_tokens: total_tokens,
5299            total_tokens,
5300        },
5301    };
5302
5303    Ok(Json(response).into_response())
5304}
5305
5306fn validate_embeddings_request(
5307    request: &EmbeddingsRequest,
5308) -> std::result::Result<(), ServerError> {
5309    if let Some(format) = request.encoding_format.as_deref() {
5310        if !format.eq_ignore_ascii_case("float") {
5311            return Err(ServerError::unsupported_feature(
5312                "only encoding_format=float is supported for embeddings",
5313                Some("encoding_format"),
5314            ));
5315        }
5316    }
5317    Ok(())
5318}
5319
5320/// Audio transcription handler (OpenAI-compatible multipart form).
5321async fn transcriptions_handler(
5322    State(state): State<AppState>,
5323    multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
5324) -> std::result::Result<Response, ServerError> {
5325    let mut multipart = multipart.map_err(|e| {
5326        ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
5327    })?;
5328
5329    let span = span!(Level::INFO, "transcription");
5330    let _enter = span.enter();
5331
5332    let mut file_data: Option<Vec<u8>> = None;
5333    let mut language: Option<String> = None;
5334    let mut response_format: Option<String> = None;
5335
5336    while let Some(field) = multipart
5337        .next_field()
5338        .await
5339        .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
5340    {
5341        let name = field.name().unwrap_or("").to_string();
5342        match name.as_str() {
5343            "file" => {
5344                file_data = Some(
5345                    field
5346                        .bytes()
5347                        .await
5348                        .map_err(|e| {
5349                            ServerError::invalid_request(format!("read file: {e}"), Some("file"))
5350                        })?
5351                        .to_vec(),
5352                );
5353            }
5354            "language" => {
5355                language = field.text().await.ok().filter(|s| !s.is_empty());
5356            }
5357            "response_format" => {
5358                response_format = field.text().await.ok().filter(|s| !s.is_empty());
5359            }
5360            _ => {} // ignore model and other optional multipart fields for now
5361        }
5362    }
5363
5364    validate_transcription_response_format(response_format.as_deref())?;
5365
5366    let data = file_data
5367        .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
5368
5369    let engine = state.transcribe.as_ref().ok_or_else(|| {
5370        ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
5371    })?;
5372    let text = engine
5373        .transcribe_bytes(&data, language.as_deref())
5374        .await
5375        .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
5376
5377    Ok(Json(TranscriptionResponse { text }).into_response())
5378}
5379
5380fn validate_transcription_response_format(
5381    response_format: Option<&str>,
5382) -> std::result::Result<(), ServerError> {
5383    if let Some(format) = response_format {
5384        if !format.eq_ignore_ascii_case("json") {
5385            return Err(ServerError::unsupported_feature(
5386                "only response_format=json is supported for transcriptions",
5387                Some("response_format"),
5388            ));
5389        }
5390    }
5391    Ok(())
5392}
5393
5394/// TTS speech synthesis handler (OpenAI-compatible /v1/audio/speech)
5395async fn speech_handler(
5396    State(state): State<AppState>,
5397    request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
5398) -> std::result::Result<Response, ServerError> {
5399    let Json(request) = request
5400        .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
5401
5402    let response_format = speech_output_format(&request)?;
5403    resolve_request_model(
5404        &state.served_model_registry,
5405        &request.model,
5406        ServedModelKind::Speech,
5407    )?;
5408
5409    let span = span!(Level::INFO, "speech");
5410    let _guard = span.enter();
5411
5412    let language = if request.language.is_empty() || request.language == "auto" {
5413        None
5414    } else {
5415        Some(request.language.as_str())
5416    };
5417
5418    let chunk_frames = 10usize;
5419    let tts = state.tts.as_ref().ok_or_else(|| {
5420        ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
5421    })?;
5422    let sample_rate = tts.tts_sample_rate();
5423
5424    if request.stream {
5425        // Streaming: chunked transfer encoding with WAV audio
5426        let (tx, rx) =
5427            mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
5428
5429        let engine = tts.clone();
5430        let text = request.input.clone();
5431        let lang = request.language.clone();
5432
5433        tokio::task::spawn_blocking(move || {
5434            let lang_opt = if lang.is_empty() || lang == "auto" {
5435                None
5436            } else {
5437                Some(lang.as_str())
5438            };
5439            let rt = tokio::runtime::Handle::current();
5440
5441            match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
5442                Ok(chunks) => {
5443                    for chunk in &chunks {
5444                        let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
5445                        let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
5446                    }
5447                }
5448                Err(e) => {
5449                    error!("TTS error: {e}");
5450                }
5451            }
5452        });
5453
5454        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
5455        let body = axum::body::Body::from_stream(stream);
5456        Ok(Response::builder()
5457            .status(200)
5458            .header("content-type", speech_content_type(response_format))
5459            .header("transfer-encoding", "chunked")
5460            .body(body)
5461            .unwrap())
5462    } else {
5463        // Non-streaming: return complete WAV
5464        let chunks = tts
5465            .synthesize_speech(&request.input, language, chunk_frames)
5466            .await
5467            .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
5468
5469        let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
5470        let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
5471
5472        Ok(Response::builder()
5473            .status(200)
5474            .header("content-type", speech_content_type(response_format))
5475            .header("content-length", audio_bytes.len().to_string())
5476            .body(axum::body::Body::from(audio_bytes))
5477            .unwrap())
5478    }
5479}
5480
5481#[derive(Clone, Copy)]
5482enum SpeechOutputFormat {
5483    Wav,
5484    Pcm,
5485}
5486
5487fn speech_output_format(
5488    request: &SpeechRequest,
5489) -> std::result::Result<SpeechOutputFormat, ServerError> {
5490    if request.response_format.eq_ignore_ascii_case("wav") {
5491        Ok(SpeechOutputFormat::Wav)
5492    } else if request.response_format.eq_ignore_ascii_case("pcm") {
5493        Ok(SpeechOutputFormat::Pcm)
5494    } else {
5495        Err(ServerError::unsupported_feature(
5496            "only response_format=wav or response_format=pcm is supported for speech",
5497            Some("response_format"),
5498        ))
5499    }
5500}
5501
5502fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
5503    match format {
5504        SpeechOutputFormat::Wav => "audio/wav",
5505        SpeechOutputFormat::Pcm => "audio/pcm",
5506    }
5507}
5508
5509fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
5510    match format {
5511        SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
5512        SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
5513    }
5514}
5515
5516/// Convert PCM f32 samples to WAV bytes (16-bit, mono).
5517fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
5518    let num_samples = samples.len();
5519    let data_size = num_samples * 2; // 16-bit = 2 bytes per sample
5520    let file_size = 44 + data_size;
5521
5522    let mut buf = Vec::with_capacity(file_size);
5523    // RIFF header
5524    buf.extend_from_slice(b"RIFF");
5525    buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
5526    buf.extend_from_slice(b"WAVE");
5527    // fmt chunk
5528    buf.extend_from_slice(b"fmt ");
5529    buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
5530    buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
5531    buf.extend_from_slice(&1u16.to_le_bytes()); // mono
5532    buf.extend_from_slice(&sample_rate.to_le_bytes());
5533    buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
5534    buf.extend_from_slice(&2u16.to_le_bytes()); // block align
5535    buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
5536                                                 // data chunk
5537    buf.extend_from_slice(b"data");
5538    buf.extend_from_slice(&(data_size as u32).to_le_bytes());
5539    buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
5540    buf
5541}
5542
5543fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
5544    let mut buf = Vec::with_capacity(samples.len() * 2);
5545    for &s in samples {
5546        let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
5547        buf.extend_from_slice(&i16_val.to_le_bytes());
5548    }
5549    buf
5550}
5551
5552async fn models_handler(
5553    State(state): State<AppState>,
5554) -> std::result::Result<Response, ServerError> {
5555    let now = chrono::Utc::now().timestamp() as u64;
5556    let reasoning = state.prompt_template.as_ref().and_then(|template| {
5557        let supported_efforts = template
5558            .reasoning_effort_support
5559            .declared_efforts()
5560            .map(|efforts| efforts.iter().copied().collect());
5561        let thinking =
5562            template
5563                .supports_thinking_control()
5564                .then(|| crate::openai::ModelThinkingCapability {
5565                    default_enabled: state
5566                        .default_enable_thinking
5567                        .unwrap_or(template.reasoning_default_enabled),
5568                });
5569        (supported_efforts.is_some() || thinking.is_some()).then_some(
5570            crate::openai::ModelReasoningCapabilities {
5571                supported_efforts,
5572                thinking,
5573            },
5574        )
5575    });
5576    let data = state
5577        .served_model_registry
5578        .entries()
5579        .iter()
5580        .map(|entry| crate::openai::ModelInfo {
5581            id: entry.public_name().to_string(),
5582            object: "model".to_string(),
5583            created: now,
5584            owned_by: "ferrum".to_string(),
5585            max_model_len: match entry.kind() {
5586                ServedModelKind::Llm => state.llm.as_ref().and_then(|llm| llm.context_capacity()),
5587                _ => None,
5588            },
5589            reasoning: if entry.kind() == ServedModelKind::Llm
5590                && state
5591                    .llm
5592                    .as_ref()
5593                    .is_some_and(|llm| &llm.config().model.model_id == entry.engine_model_id())
5594            {
5595                reasoning.clone()
5596            } else {
5597                None
5598            },
5599            modalities: entry
5600                .kind()
5601                .modalities()
5602                .iter()
5603                .map(ToString::to_string)
5604                .collect(),
5605            permission: vec![],
5606            root: entry.parent_public_name().map(ToString::to_string),
5607            parent: entry.parent_public_name().map(ToString::to_string),
5608        })
5609        .collect();
5610
5611    let models = ModelListResponse {
5612        object: "list".to_string(),
5613        data,
5614    };
5615
5616    Ok(Json(models).into_response())
5617}
5618
5619async fn health_handler(
5620    State(state): State<AppState>,
5621) -> std::result::Result<Response, ServerError> {
5622    let engine_status = state.status().await;
5623    let scheduler_metrics = state.metrics();
5624    let runtime_config = RuntimeConfigSnapshot::capture_current();
5625    let cache_policy = CachePolicy::current();
5626    let engine_cache = state
5627        .llm
5628        .as_ref()
5629        .and_then(|engine| engine.cache_metrics_snapshot());
5630    let execution_attribution = state
5631        .llm
5632        .as_ref()
5633        .and_then(|engine| engine.execution_attribution_snapshot());
5634    let engine_lora = state
5635        .llm
5636        .as_ref()
5637        .and_then(|engine| engine.lora_metrics_snapshot());
5638    let auto_config = auto_config_health_value(state.auto_config.as_ref());
5639    let runtime_admission = match state.llm.as_ref() {
5640        Some(engine) => engine.admission_snapshot(),
5641        None => Ok(None),
5642    };
5643    let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5644        Ok(snapshot) => (snapshot.as_ref(), None),
5645        Err(error) => (None, Some(error.to_string())),
5646    };
5647    let admission = admission_health_json(
5648        &engine_status,
5649        &scheduler_metrics,
5650        &auto_config,
5651        runtime_admission_snapshot,
5652        runtime_admission_error.as_deref(),
5653    );
5654
5655    let health = serde_json::json!({
5656        "status": if runtime_admission_error.is_some() { "unhealthy" } else { "healthy" },
5657        "reasoning_protocol": state.prompt_template.as_deref().map(ModelChatTemplate::reasoning_capability).unwrap_or_default(),
5658        "timestamp": chrono::Utc::now().to_rfc3339(),
5659        "version": env!("CARGO_PKG_VERSION"),
5660        "engine": {
5661            "active_requests": engine_status.active_requests,
5662            "queued_requests": engine_status.queued_requests,
5663        },
5664        "scheduler": {
5665            "total_requests": scheduler_metrics.total_requests,
5666            "successful_requests": scheduler_metrics.successful_requests,
5667            "failed_requests": scheduler_metrics.failed_requests,
5668            "throughput_rps": scheduler_metrics.throughput_rps,
5669            "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
5670            "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
5671            "model_execution_time_ms": scheduler_metrics
5672                .performance_breakdown
5673                .model_execution_time_ms,
5674            "iteration_lock_wait_time_ms": scheduler_metrics
5675                .performance_breakdown
5676                .other_overhead_time_ms,
5677        },
5678        "config": runtime_config,
5679        "auto_config": auto_config,
5680        "admission": admission,
5681        "numerical_execution": engine_cache.as_ref().and_then(|snapshot| snapshot.get("numerical_execution")),
5682        "kv_storage": engine_cache.as_ref().and_then(|snapshot| snapshot.get("kv_storage")),
5683        "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
5684        "execution_attribution": execution_attribution,
5685        "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
5686            "enabled": state.served_model_registry.adapter_count() > 0,
5687            "adapter_count": state.served_model_registry.adapter_count() as u64,
5688            "active_cache_bindings": 0u64,
5689            "projection_applications": 0u64,
5690            "position": "startup-routing",
5691            "source": "server-lora-registry",
5692        })),
5693    });
5694
5695    Ok(Json(health).into_response())
5696}
5697
5698/// Prometheus metrics endpoint — returns metrics in Prometheus text format.
5699async fn metrics_handler(
5700    State(state): State<AppState>,
5701) -> std::result::Result<Response, ServerError> {
5702    let mut body = match PROM_HANDLE.get() {
5703        Some(handle) => handle.render(),
5704        None => "# Prometheus recorder not initialized\n".to_string(),
5705    };
5706    if !body.ends_with('\n') {
5707        body.push('\n');
5708    }
5709    let engine_cache = state
5710        .llm
5711        .as_ref()
5712        .and_then(|engine| engine.cache_metrics_snapshot());
5713    body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
5714    let engine_status = state.status().await;
5715    let scheduler_metrics = state.metrics();
5716    let auto_config = auto_config_health_value(state.auto_config.as_ref());
5717    let runtime_admission = match state.llm.as_ref() {
5718        Some(engine) => engine.admission_snapshot(),
5719        None => Ok(None),
5720    };
5721    let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5722        Ok(snapshot) => (snapshot.as_ref(), None),
5723        Err(error) => (None, Some(error.to_string())),
5724    };
5725    let admission = admission_health_json(
5726        &engine_status,
5727        &scheduler_metrics,
5728        &auto_config,
5729        runtime_admission_snapshot,
5730        runtime_admission_error.as_deref(),
5731    );
5732    body.push_str(&admission_prometheus_metrics(&admission));
5733
5734    Ok((
5735        [(
5736            axum::http::header::CONTENT_TYPE,
5737            "text/plain; version=0.0.4; charset=utf-8",
5738        )],
5739        body,
5740    )
5741        .into_response())
5742}
5743
5744async fn root_handler() -> std::result::Result<Response, ServerError> {
5745    let info = serde_json::json!({
5746        "name": "Ferrum Inference Server",
5747        "version": env!("CARGO_PKG_VERSION"),
5748        "api_version": "v1",
5749        "status": "running"
5750    });
5751
5752    Ok(Json(info).into_response())
5753}
5754
5755/// Server error type for HTTP responses
5756#[derive(Debug)]
5757enum ServerError {
5758    InvalidRequest {
5759        message: String,
5760        param: Option<String>,
5761    },
5762    UnsupportedFeature {
5763        message: String,
5764        param: Option<String>,
5765    },
5766    InternalError(String),
5767    ContextLengthExceeded(String),
5768    NotImplemented(String),
5769    ServiceUnavailable(String),
5770}
5771
5772impl ServerError {
5773    fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
5774        Self::InvalidRequest {
5775            message: message.into(),
5776            param: param.map(str::to_string),
5777        }
5778    }
5779
5780    fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
5781        Self::UnsupportedFeature {
5782            message: message.into(),
5783            param: param.map(str::to_string),
5784        }
5785    }
5786}
5787
5788impl IntoResponse for ServerError {
5789    fn into_response(self) -> Response {
5790        let code = matches!(&self, ServerError::ContextLengthExceeded(_))
5791            .then(|| "context_length_exceeded".to_owned());
5792        let (status, message, error_type, param) = match self {
5793            ServerError::ContextLengthExceeded(message) => (
5794                AxumStatusCode::BAD_REQUEST,
5795                message,
5796                "invalid_request_error",
5797                None,
5798            ),
5799            ServerError::InvalidRequest { message, param } => (
5800                AxumStatusCode::BAD_REQUEST,
5801                message,
5802                "invalid_request_error",
5803                param,
5804            ),
5805            ServerError::UnsupportedFeature { message, param } => (
5806                AxumStatusCode::BAD_REQUEST,
5807                message,
5808                "invalid_request_error",
5809                param,
5810            ),
5811            ServerError::InternalError(msg) => (
5812                AxumStatusCode::INTERNAL_SERVER_ERROR,
5813                msg,
5814                "internal_server_error",
5815                None,
5816            ),
5817            ServerError::NotImplemented(msg) => (
5818                AxumStatusCode::SERVICE_UNAVAILABLE,
5819                msg,
5820                "service_unavailable_error",
5821                None,
5822            ),
5823            ServerError::ServiceUnavailable(msg) => (
5824                AxumStatusCode::SERVICE_UNAVAILABLE,
5825                msg,
5826                "service_unavailable_error",
5827                None,
5828            ),
5829        };
5830
5831        let error = OpenAiError {
5832            error: OpenAiErrorDetail {
5833                message,
5834                error_type: error_type.to_string(),
5835                param,
5836                code,
5837            },
5838        };
5839
5840        (status, Json(error)).into_response()
5841    }
5842}
5843
5844impl std::fmt::Display for MessageRole {
5845    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5846        match self {
5847            MessageRole::System => write!(f, "system"),
5848            MessageRole::User => write!(f, "user"),
5849            MessageRole::Assistant => write!(f, "assistant"),
5850            MessageRole::Function => write!(f, "function"),
5851            MessageRole::Tool => write!(f, "tool"),
5852        }
5853    }
5854}
5855
5856/// Strip model output at the first user-supplied stop sequence.
5857/// OpenAI-compatible `stop` strings are generation boundaries and must not
5858/// be returned to the caller, even if the model continued after the boundary.
5859fn strip_after_stop(text: &str, stops: &[String]) -> String {
5860    let mut first: Option<usize> = None;
5861    for stop in stops {
5862        if stop.is_empty() {
5863            continue;
5864        }
5865        if let Some(idx) = text.find(stop.as_str()) {
5866            first = Some(first.map_or(idx, |current| current.min(idx)));
5867        }
5868    }
5869    match first {
5870        Some(idx) => text[..idx].to_string(),
5871        None => text.to_string(),
5872    }
5873}
5874
5875/// Compatibility cleanup for explicitly best-effort structured output.
5876/// Hard `json_object` and strict schema contracts never call this helper.
5877fn strip_markdown_json_fence(text: &str) -> String {
5878    let trimmed = text.trim();
5879    // Try the most specific marker first.
5880    for prefix in ["```json\n", "```json", "```\n", "```"] {
5881        if let Some(rest) = trimmed.strip_prefix(prefix) {
5882            if let Some(inner) = rest.strip_suffix("```") {
5883                return inner.trim().to_string();
5884            }
5885        }
5886    }
5887    text.to_string()
5888}
5889
5890/// Convert FinishReason to OpenAI API string
5891fn finish_reason_to_string(reason: &FinishReason) -> String {
5892    match reason {
5893        FinishReason::Length => "length".to_string(),
5894        FinishReason::Stop => "stop".to_string(),
5895        FinishReason::EOS => "stop".to_string(),
5896        FinishReason::Cancelled => "cancelled".to_string(),
5897        FinishReason::Error => "error".to_string(),
5898        FinishReason::ContentFilter => "content_filter".to_string(),
5899    }
5900}
5901
5902#[cfg(test)]
5903mod tests {
5904    mod auto_tools_json;
5905    mod engine_stop_contract;
5906    mod gemma_thought;
5907    mod harmony_stops;
5908    mod model_reasoning_metadata;
5909    mod native_tool_stream;
5910    mod reasoning_controls;
5911    mod tool_argument_strictness;
5912    mod tool_length;
5913    use super::*;
5914    use async_trait::async_trait;
5915    use axum::{
5916        body::{to_bytes, Body},
5917        http::{header, Request},
5918        response::Response,
5919    };
5920    use ferrum_interfaces::engine::{
5921        EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
5922    };
5923    use ferrum_types::{
5924        has_unclosed_thinking_block, parse_reasoning_response_started_in_think, EngineConfig,
5925        EngineMetrics, EngineStatus, EngineTokenTimingEvidence, FinishReason,
5926        HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
5927        ModelId, StreamChunk, TokenId, TokenUsage,
5928    };
5929    use futures::{stream, Stream};
5930    use serde_json::{json, Value};
5931    use std::{
5932        collections::HashMap,
5933        pin::Pin,
5934        sync::{atomic::AtomicUsize, Arc, Mutex},
5935    };
5936    use tower::ServiceExt;
5937
5938    #[test]
5939    fn strip_after_stop_removes_first_boundary() {
5940        assert_eq!(
5941            strip_after_stop(
5942                "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
5943                &["END0214Z".to_string()]
5944            ),
5945            "KS0214Z\nS0225\n"
5946        );
5947    }
5948
5949    #[test]
5950    fn gpt_oss_harmony_final_is_split_into_reasoning_and_visible_content() {
5951        let parsed = parse_chat_model_output(
5952            ModelOutputProtocol::HarmonyGptOss,
5953            "<|channel|>analysis<|message|>Reason.<|end|>\
5954             <|start|>assistant<|channel|>final<|message|>Answer.<|return|>",
5955            false,
5956            FinishReason::Stop,
5957        )
5958        .unwrap();
5959        assert_eq!(parsed.visible.content, "Answer.");
5960        assert_eq!(parsed.visible.reasoning.as_deref(), Some("Reason."));
5961        assert!(parsed.harmony_response.is_none());
5962    }
5963
5964    #[test]
5965    fn gpt_oss_harmony_tool_call_becomes_openai_structured_response() {
5966        let parsed = parse_chat_model_output(
5967            ModelOutputProtocol::HarmonyGptOss,
5968            "<|channel|>analysis<|message|>Need weather.<|end|>\
5969             <|start|>assistant<|channel|>commentary to=functions.weather\
5970             <|constrain|>json<|message|>{\"city\":\"Paris\"}<|call|>",
5971            false,
5972            FinishReason::Stop,
5973        )
5974        .unwrap();
5975        let response = parsed.harmony_response.unwrap();
5976        assert_eq!(response.finish_reason.as_deref(), Some("tool_calls"));
5977        assert_eq!(response.message.tool_calls.len(), 1);
5978        assert_eq!(response.message.tool_calls[0].function.name, "weather");
5979        assert_eq!(
5980            response.message.tool_calls[0].function.arguments,
5981            "{\"city\":\"Paris\"}"
5982        );
5983        assert!(response.message.tool_calls[0].id.starts_with("call_"));
5984    }
5985
5986    #[test]
5987    fn gpt_oss_harmony_accepts_missing_text_terminal_only_for_explicit_truncation() {
5988        let output = "<|channel|>analysis<|message|>Still reasoning";
5989        for finish_reason in [FinishReason::Stop, FinishReason::Length] {
5990            let parsed = parse_chat_model_output(
5991                ModelOutputProtocol::HarmonyGptOss,
5992                output,
5993                false,
5994                finish_reason,
5995            )
5996            .unwrap();
5997            assert_eq!(parsed.visible.reasoning.as_deref(), Some("Still reasoning"));
5998            assert!(parsed.visible.content.is_empty());
5999        }
6000        for finish_reason in [
6001            FinishReason::EOS,
6002            FinishReason::Cancelled,
6003            FinishReason::Error,
6004            FinishReason::ContentFilter,
6005        ] {
6006            assert!(parse_chat_model_output(
6007                ModelOutputProtocol::HarmonyGptOss,
6008                output,
6009                false,
6010                finish_reason,
6011            )
6012            .is_err());
6013        }
6014    }
6015
6016    #[tokio::test]
6017    async fn stop_drains_running_server_and_shuts_down_loaded_engine_once() {
6018        let engine = Arc::new(StubLlm::new("ok"));
6019        let server = Arc::new(AxumServer::from_llm(engine.clone()));
6020        let config = ServerConfig {
6021            host: "127.0.0.1".to_string(),
6022            port: 0,
6023            ..ServerConfig::default()
6024        };
6025        let server_task = {
6026            let server = Arc::clone(&server);
6027            tokio::spawn(async move { server.start(&config).await })
6028        };
6029        tokio::time::timeout(std::time::Duration::from_secs(1), async {
6030            while !server.is_running() {
6031                tokio::task::yield_now().await;
6032            }
6033        })
6034        .await
6035        .unwrap();
6036
6037        server
6038            .stop(std::time::Duration::from_secs(1))
6039            .await
6040            .unwrap();
6041        server
6042            .stop(std::time::Duration::from_secs(1))
6043            .await
6044            .unwrap();
6045        server_task.await.unwrap().unwrap();
6046
6047        assert_eq!(engine.shutdown_count.load(Ordering::Acquire), 1);
6048        assert!(!server.is_running());
6049    }
6050
6051    struct StubLlm {
6052        config: EngineConfig,
6053        context_capacity: Option<usize>,
6054        text: String,
6055        stream_chunks: Option<Vec<String>>,
6056        stream_final_chunk_separate: bool,
6057        stream_tail_without_token: bool,
6058        stream_usage: Option<TokenUsage>,
6059        api_response: Option<ferrum_types::ApiResponse>,
6060        finish_reason: FinishReason,
6061        execution_attribution: Option<Value>,
6062        lora_metrics: Option<Value>,
6063        pending_stream_drop_notify: Option<Arc<Notify>>,
6064        stream_after_first_gate: Option<Arc<StreamGate>>,
6065        stream_terminal_error: bool,
6066        shutdown_count: AtomicUsize,
6067    }
6068
6069    impl StubLlm {
6070        fn new(text: &str) -> Self {
6071            let mut config = EngineConfig::default();
6072            config.model.model_id = ModelId::new("stub-model");
6073            Self {
6074                config,
6075                text: text.to_string(),
6076                context_capacity: None,
6077                stream_chunks: None,
6078                stream_final_chunk_separate: false,
6079                stream_tail_without_token: false,
6080                stream_usage: Some(TokenUsage::new(5, 1)),
6081                api_response: None,
6082                finish_reason: FinishReason::EOS,
6083                execution_attribution: None,
6084                lora_metrics: None,
6085                pending_stream_drop_notify: None,
6086                stream_after_first_gate: None,
6087                stream_terminal_error: false,
6088                shutdown_count: AtomicUsize::new(0),
6089            }
6090        }
6091
6092        fn without_stream_usage(text: &str) -> Self {
6093            Self {
6094                stream_usage: None,
6095                ..Self::new(text)
6096            }
6097        }
6098
6099        fn with_stream_chunks(chunks: &[&str]) -> Self {
6100            Self {
6101                text: chunks.concat(),
6102                stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
6103                stream_usage: Some(TokenUsage::new(5, chunks.len())),
6104                ..Self::new("")
6105            }
6106        }
6107
6108        fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
6109            Self {
6110                text: chunks.concat(),
6111                stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
6112                stream_final_chunk_separate: true,
6113                stream_usage: Some(TokenUsage::new(5, chunks.len())),
6114                ..Self::new("")
6115            }
6116        }
6117
6118        fn with_tokenless_tail(chunks: &[&str]) -> Self {
6119            Self {
6120                stream_tail_without_token: true,
6121                ..Self::with_separate_final_stream_chunk(chunks)
6122            }
6123        }
6124
6125        fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
6126            Self {
6127                api_response: Some(api_response),
6128                ..Self::new(text)
6129            }
6130        }
6131
6132        fn with_api_response_and_finish_reason(
6133            text: &str,
6134            api_response: ferrum_types::ApiResponse,
6135            finish_reason: FinishReason,
6136        ) -> Self {
6137            Self {
6138                api_response: Some(api_response),
6139                finish_reason,
6140                ..Self::new(text)
6141            }
6142        }
6143
6144        fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
6145            Self {
6146                lora_metrics: Some(lora_metrics),
6147                ..Self::new(text)
6148            }
6149        }
6150
6151        fn with_execution_attribution(text: &str, execution_attribution: Value) -> Self {
6152            Self {
6153                execution_attribution: Some(execution_attribution),
6154                ..Self::new(text)
6155            }
6156        }
6157
6158        fn with_pending_stream(drop_notify: Arc<Notify>) -> Self {
6159            Self {
6160                pending_stream_drop_notify: Some(drop_notify),
6161                ..Self::new("")
6162            }
6163        }
6164    }
6165
6166    struct PendingDropStream {
6167        drop_notify: Arc<Notify>,
6168    }
6169
6170    #[derive(Default)]
6171    struct StreamGate {
6172        entered: Notify,
6173        resume: Notify,
6174    }
6175
6176    impl Stream for PendingDropStream {
6177        type Item = ferrum_types::Result<StreamChunk>;
6178
6179        fn poll_next(
6180            self: Pin<&mut Self>,
6181            _cx: &mut std::task::Context<'_>,
6182        ) -> std::task::Poll<Option<Self::Item>> {
6183            std::task::Poll::Pending
6184        }
6185    }
6186
6187    impl Drop for PendingDropStream {
6188        fn drop(&mut self) {
6189            self.drop_notify.notify_one();
6190        }
6191    }
6192
6193    struct StubEmbed {
6194        config: EngineConfig,
6195    }
6196
6197    impl StubEmbed {
6198        fn new() -> Self {
6199            let mut config = EngineConfig::default();
6200            config.model.model_id = ModelId::new("stub-embed");
6201            Self { config }
6202        }
6203    }
6204
6205    struct StubTranscribe {
6206        config: EngineConfig,
6207    }
6208
6209    impl StubTranscribe {
6210        fn new() -> Self {
6211            let mut config = EngineConfig::default();
6212            config.model.model_id = ModelId::new("stub-transcribe");
6213            Self { config }
6214        }
6215    }
6216
6217    struct StubTts {
6218        config: EngineConfig,
6219    }
6220
6221    impl StubTts {
6222        fn new() -> Self {
6223            let mut config = EngineConfig::default();
6224            config.model.model_id = ModelId::new("stub-tts");
6225            Self { config }
6226        }
6227    }
6228
6229    struct FailingLlm {
6230        config: EngineConfig,
6231        fail_after_stream_start: bool,
6232        infer_failure: ferrum_types::FerrumError,
6233        stream_start_failure: ferrum_types::FerrumError,
6234        stream_chunk_failure: ferrum_types::FerrumError,
6235    }
6236
6237    impl FailingLlm {
6238        fn new() -> Self {
6239            let mut config = EngineConfig::default();
6240            config.model.model_id = ModelId::new("failing-model");
6241            Self {
6242                config,
6243                fail_after_stream_start: false,
6244                infer_failure: ferrum_types::FerrumError::internal("stub generation failed"),
6245                stream_start_failure: ferrum_types::FerrumError::internal("stub stream failed"),
6246                stream_chunk_failure: ferrum_types::FerrumError::internal(
6247                    "stub stream chunk failed",
6248                ),
6249            }
6250        }
6251
6252        fn after_stream_start() -> Self {
6253            Self {
6254                fail_after_stream_start: true,
6255                ..Self::new()
6256            }
6257        }
6258
6259        fn resource_exhausted() -> Self {
6260            let failure = ferrum_types::FerrumError::resource_exhausted(
6261                "admission capacity exhausted while reserving request resources",
6262            );
6263            Self {
6264                infer_failure: failure.clone(),
6265                stream_start_failure: failure.clone(),
6266                stream_chunk_failure: failure,
6267                ..Self::new()
6268            }
6269        }
6270
6271        fn context_length_exceeded() -> Self {
6272            let failure = ferrum_types::FerrumError::ContextLengthExceeded {
6273                capacity: 512,
6274                input_tokens: 500,
6275                output_tokens: 100,
6276            };
6277            Self {
6278                infer_failure: failure.clone(),
6279                stream_start_failure: failure,
6280                ..Self::new()
6281            }
6282        }
6283    }
6284
6285    struct CapturingLlm {
6286        config: EngineConfig,
6287        last_request: Mutex<Option<InferenceRequest>>,
6288    }
6289
6290    impl CapturingLlm {
6291        fn new() -> Self {
6292            let mut config = EngineConfig::default();
6293            config.model.model_id = ModelId::new("qwen3");
6294            Self {
6295                config,
6296                last_request: Mutex::new(None),
6297            }
6298        }
6299
6300        fn last_request(&self) -> InferenceRequest {
6301            self.last_request
6302                .lock()
6303                .expect("capture lock")
6304                .clone()
6305                .expect("request captured")
6306        }
6307
6308        fn has_captured_request(&self) -> bool {
6309            self.last_request.lock().expect("capture lock").is_some()
6310        }
6311    }
6312
6313    #[async_trait]
6314    impl InferenceEngine for StubLlm {
6315        async fn status(&self) -> EngineStatus {
6316            EngineStatus {
6317                is_ready: true,
6318                loaded_models: vec![self.config.model.model_id.clone()],
6319                active_requests: 0,
6320                queued_requests: 0,
6321                memory_usage: MemoryUsage {
6322                    total_bytes: 0,
6323                    used_bytes: 0,
6324                    free_bytes: 0,
6325                    gpu_memory_bytes: None,
6326                    cpu_memory_bytes: None,
6327                    cache_memory_bytes: 0,
6328                    utilization_percent: 0.0,
6329                },
6330                uptime_seconds: 0,
6331                last_heartbeat: chrono::Utc::now(),
6332                version: "test".to_string(),
6333            }
6334        }
6335
6336        async fn shutdown(&self) -> ferrum_types::Result<()> {
6337            self.shutdown_count.fetch_add(1, Ordering::AcqRel);
6338            Ok(())
6339        }
6340
6341        fn config(&self) -> &EngineConfig {
6342            &self.config
6343        }
6344
6345        fn metrics(&self) -> EngineMetrics {
6346            EngineMetrics::default()
6347        }
6348
6349        async fn health_check(&self) -> EngineHealthStatus {
6350            EngineHealthStatus::healthy()
6351        }
6352
6353        fn execution_attribution_snapshot(&self) -> Option<Value> {
6354            self.execution_attribution.clone()
6355        }
6356
6357        fn lora_metrics_snapshot(&self) -> Option<Value> {
6358            self.lora_metrics.clone()
6359        }
6360    }
6361
6362    #[async_trait]
6363    impl InferenceEngine for StubEmbed {
6364        async fn status(&self) -> EngineStatus {
6365            EngineStatus {
6366                is_ready: true,
6367                loaded_models: vec![self.config.model.model_id.clone()],
6368                active_requests: 0,
6369                queued_requests: 0,
6370                memory_usage: MemoryUsage {
6371                    total_bytes: 0,
6372                    used_bytes: 0,
6373                    free_bytes: 0,
6374                    gpu_memory_bytes: None,
6375                    cpu_memory_bytes: None,
6376                    cache_memory_bytes: 0,
6377                    utilization_percent: 0.0,
6378                },
6379                uptime_seconds: 0,
6380                last_heartbeat: chrono::Utc::now(),
6381                version: "test".to_string(),
6382            }
6383        }
6384
6385        async fn shutdown(&self) -> ferrum_types::Result<()> {
6386            Ok(())
6387        }
6388
6389        fn config(&self) -> &EngineConfig {
6390            &self.config
6391        }
6392
6393        fn metrics(&self) -> EngineMetrics {
6394            EngineMetrics::default()
6395        }
6396
6397        async fn health_check(&self) -> EngineHealthStatus {
6398            EngineHealthStatus::healthy()
6399        }
6400    }
6401
6402    #[async_trait]
6403    impl EmbedEngine for StubEmbed {
6404        async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
6405            Ok(vec![text.len() as f32, 1.0, 0.0])
6406        }
6407
6408        async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
6409            Ok(vec![image.len() as f32, 0.0, 1.0])
6410        }
6411
6412        fn embedding_dim(&self) -> usize {
6413            3
6414        }
6415    }
6416
6417    #[async_trait]
6418    impl InferenceEngine for StubTranscribe {
6419        async fn status(&self) -> EngineStatus {
6420            EngineStatus {
6421                is_ready: true,
6422                loaded_models: vec![self.config.model.model_id.clone()],
6423                active_requests: 0,
6424                queued_requests: 0,
6425                memory_usage: MemoryUsage {
6426                    total_bytes: 0,
6427                    used_bytes: 0,
6428                    free_bytes: 0,
6429                    gpu_memory_bytes: None,
6430                    cpu_memory_bytes: None,
6431                    cache_memory_bytes: 0,
6432                    utilization_percent: 0.0,
6433                },
6434                uptime_seconds: 0,
6435                last_heartbeat: chrono::Utc::now(),
6436                version: "test".to_string(),
6437            }
6438        }
6439
6440        async fn shutdown(&self) -> ferrum_types::Result<()> {
6441            Ok(())
6442        }
6443
6444        fn config(&self) -> &EngineConfig {
6445            &self.config
6446        }
6447
6448        fn metrics(&self) -> EngineMetrics {
6449            EngineMetrics::default()
6450        }
6451
6452        async fn health_check(&self) -> EngineHealthStatus {
6453            EngineHealthStatus::healthy()
6454        }
6455    }
6456
6457    #[async_trait]
6458    impl TranscribeEngine for StubTranscribe {
6459        async fn transcribe_file(
6460            &self,
6461            path: &str,
6462            language: Option<&str>,
6463        ) -> ferrum_types::Result<String> {
6464            Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
6465        }
6466
6467        async fn transcribe_bytes(
6468            &self,
6469            data: &[u8],
6470            language: Option<&str>,
6471        ) -> ferrum_types::Result<String> {
6472            Ok(format!(
6473                "bytes:{}:{}",
6474                data.len(),
6475                language.unwrap_or("auto")
6476            ))
6477        }
6478    }
6479
6480    #[async_trait]
6481    impl InferenceEngine for StubTts {
6482        async fn status(&self) -> EngineStatus {
6483            EngineStatus {
6484                is_ready: true,
6485                loaded_models: vec![self.config.model.model_id.clone()],
6486                active_requests: 0,
6487                queued_requests: 0,
6488                memory_usage: MemoryUsage {
6489                    total_bytes: 0,
6490                    used_bytes: 0,
6491                    free_bytes: 0,
6492                    gpu_memory_bytes: None,
6493                    cpu_memory_bytes: None,
6494                    cache_memory_bytes: 0,
6495                    utilization_percent: 0.0,
6496                },
6497                uptime_seconds: 0,
6498                last_heartbeat: chrono::Utc::now(),
6499                version: "test".to_string(),
6500            }
6501        }
6502
6503        async fn shutdown(&self) -> ferrum_types::Result<()> {
6504            Ok(())
6505        }
6506
6507        fn config(&self) -> &EngineConfig {
6508            &self.config
6509        }
6510
6511        fn metrics(&self) -> EngineMetrics {
6512            EngineMetrics::default()
6513        }
6514
6515        async fn health_check(&self) -> EngineHealthStatus {
6516            EngineHealthStatus::healthy()
6517        }
6518    }
6519
6520    #[async_trait]
6521    impl TtsEngine for StubTts {
6522        async fn synthesize_speech(
6523            &self,
6524            _text: &str,
6525            _language: Option<&str>,
6526            _chunk_frames: usize,
6527        ) -> ferrum_types::Result<Vec<Vec<f32>>> {
6528            Ok(vec![vec![0.0, 0.5, -0.5]])
6529        }
6530
6531        fn tts_sample_rate(&self) -> u32 {
6532            16_000
6533        }
6534    }
6535
6536    #[async_trait]
6537    impl InferenceEngine for FailingLlm {
6538        async fn status(&self) -> EngineStatus {
6539            EngineStatus {
6540                is_ready: true,
6541                loaded_models: vec![self.config.model.model_id.clone()],
6542                active_requests: 0,
6543                queued_requests: 0,
6544                memory_usage: MemoryUsage {
6545                    total_bytes: 0,
6546                    used_bytes: 0,
6547                    free_bytes: 0,
6548                    gpu_memory_bytes: None,
6549                    cpu_memory_bytes: None,
6550                    cache_memory_bytes: 0,
6551                    utilization_percent: 0.0,
6552                },
6553                uptime_seconds: 0,
6554                last_heartbeat: chrono::Utc::now(),
6555                version: "test".to_string(),
6556            }
6557        }
6558
6559        async fn shutdown(&self) -> ferrum_types::Result<()> {
6560            Ok(())
6561        }
6562
6563        fn config(&self) -> &EngineConfig {
6564            &self.config
6565        }
6566
6567        fn metrics(&self) -> EngineMetrics {
6568            EngineMetrics::default()
6569        }
6570
6571        async fn health_check(&self) -> EngineHealthStatus {
6572            EngineHealthStatus::healthy()
6573        }
6574    }
6575
6576    #[async_trait]
6577    impl InferenceEngine for CapturingLlm {
6578        async fn status(&self) -> EngineStatus {
6579            EngineStatus {
6580                is_ready: true,
6581                loaded_models: vec![self.config.model.model_id.clone()],
6582                active_requests: 0,
6583                queued_requests: 0,
6584                memory_usage: MemoryUsage {
6585                    total_bytes: 0,
6586                    used_bytes: 0,
6587                    free_bytes: 0,
6588                    gpu_memory_bytes: None,
6589                    cpu_memory_bytes: None,
6590                    cache_memory_bytes: 0,
6591                    utilization_percent: 0.0,
6592                },
6593                uptime_seconds: 0,
6594                last_heartbeat: chrono::Utc::now(),
6595                version: "test".to_string(),
6596            }
6597        }
6598
6599        async fn shutdown(&self) -> ferrum_types::Result<()> {
6600            Ok(())
6601        }
6602
6603        fn config(&self) -> &EngineConfig {
6604            &self.config
6605        }
6606
6607        fn metrics(&self) -> EngineMetrics {
6608            EngineMetrics::default()
6609        }
6610
6611        async fn health_check(&self) -> EngineHealthStatus {
6612            EngineHealthStatus::healthy()
6613        }
6614    }
6615
6616    fn stub_execution_evidence(
6617        request: &InferenceRequest,
6618        output_token_count: usize,
6619    ) -> Option<InferenceExecutionEvidence> {
6620        let requested = &request.evidence_request;
6621        if !requested.capture_prompt_token_ids && !requested.capture_engine_token_timing {
6622            return None;
6623        }
6624        Some(InferenceExecutionEvidence {
6625            prompt_token_ids: requested
6626                .capture_prompt_token_ids
6627                .then(|| vec![TokenId::new(101), TokenId::new(202), TokenId::new(303)])
6628                .unwrap_or_default(),
6629            output_token_ids: (0..output_token_count)
6630                .map(|index| TokenId::new(11 + index as u32))
6631                .collect(),
6632            engine_token_timing: requested.capture_engine_token_timing.then(|| {
6633                EngineTokenTimingEvidence {
6634                    clock_source: "rust_std_instant".to_string(),
6635                    wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
6636                    wall_anchor_max_error_nanos: 500,
6637                    decode_ready_nanos_since_request_start: Some(1_000_000),
6638                    token_commit_nanos_since_request_start: (1..=output_token_count)
6639                        .map(|ordinal| ordinal as u64 * 1_000_000)
6640                        .collect(),
6641                    decode_stage_intervals: Vec::new(),
6642                }
6643            }),
6644        })
6645    }
6646
6647    #[async_trait]
6648    impl LlmInferenceEngine for StubLlm {
6649        fn context_capacity(&self) -> Option<usize> {
6650            self.context_capacity
6651        }
6652
6653        async fn infer(
6654            &self,
6655            request: InferenceRequest,
6656        ) -> ferrum_types::Result<InferenceResponse> {
6657            let execution_evidence = stub_execution_evidence(&request, 2);
6658            Ok(InferenceResponse {
6659                request_id: request.id,
6660                text: self.text.clone(),
6661                tokens: vec![TokenId::new(11), TokenId::new(12)],
6662                finish_reason: self.finish_reason,
6663                usage: TokenUsage::new(7, 2),
6664                latency_ms: 1,
6665                created_at: chrono::Utc::now(),
6666                metadata: HashMap::new(),
6667                api_response: self.api_response.clone(),
6668                execution_evidence,
6669            })
6670        }
6671
6672        async fn infer_stream(
6673            &self,
6674            request: InferenceRequest,
6675        ) -> ferrum_types::Result<
6676            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6677        > {
6678            if let Some(drop_notify) = self.pending_stream_drop_notify.as_ref() {
6679                return Ok(Box::pin(PendingDropStream {
6680                    drop_notify: Arc::clone(drop_notify),
6681                }));
6682            }
6683            if let Some(chunks) = &self.stream_chunks {
6684                let completion_token_count = self
6685                    .stream_usage
6686                    .as_ref()
6687                    .map(|usage| usage.completion_tokens)
6688                    .unwrap_or(chunks.len());
6689                let execution_evidence = stub_execution_evidence(&request, completion_token_count);
6690                let request_id = request.id;
6691                let mut stream_chunks = Vec::with_capacity(
6692                    chunks.len() + usize::from(self.stream_final_chunk_separate),
6693                );
6694                let last = chunks.len().saturating_sub(1);
6695                for (index, text) in chunks.iter().enumerate() {
6696                    let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
6697                    stream_chunks.push(Ok(StreamChunk {
6698                        request_id: request_id.clone(),
6699                        text: text.clone(),
6700                        token: (!(self.stream_tail_without_token && index == last))
6701                            .then_some(TokenId::new(11 + index as u32)),
6702                        finish_reason: is_final_text_chunk.then_some(self.finish_reason),
6703                        usage: is_final_text_chunk
6704                            .then(|| self.stream_usage.clone())
6705                            .flatten(),
6706                        created_at: chrono::Utc::now(),
6707                        metadata: HashMap::new(),
6708                        api_response: is_final_text_chunk
6709                            .then(|| self.api_response.clone())
6710                            .flatten(),
6711                        execution_evidence: is_final_text_chunk
6712                            .then(|| execution_evidence.clone())
6713                            .flatten(),
6714                    }));
6715                }
6716                if self.stream_final_chunk_separate {
6717                    stream_chunks.push(Ok(StreamChunk {
6718                        request_id,
6719                        text: String::new(),
6720                        token: None,
6721                        finish_reason: Some(self.finish_reason),
6722                        usage: self.stream_usage.clone(),
6723                        created_at: chrono::Utc::now(),
6724                        metadata: HashMap::new(),
6725                        api_response: self.api_response.clone(),
6726                        execution_evidence,
6727                    }));
6728                }
6729                if self.stream_terminal_error {
6730                    *stream_chunks.last_mut().expect("nonempty fixture stream") = Err(
6731                        ferrum_types::FerrumError::internal("fixture generation failed"),
6732                    );
6733                }
6734                if let Some(gate) = self.stream_after_first_gate.clone() {
6735                    return Ok(Box::pin(stream::unfold(
6736                        (stream_chunks.into_iter().enumerate(), gate),
6737                        |(mut chunks, gate)| async move {
6738                            let (index, chunk) = chunks.next()?;
6739                            if index == 1 {
6740                                gate.entered.notify_one();
6741                                gate.resume.notified().await;
6742                            }
6743                            Some((chunk, (chunks, gate)))
6744                        },
6745                    )));
6746                }
6747                return Ok(Box::pin(stream::iter(stream_chunks)));
6748            }
6749
6750            let execution_evidence = stub_execution_evidence(&request, 1);
6751            let chunk = StreamChunk {
6752                request_id: request.id,
6753                text: self.text.clone(),
6754                token: Some(TokenId::new(11)),
6755                finish_reason: Some(self.finish_reason),
6756                usage: self.stream_usage.clone(),
6757                created_at: chrono::Utc::now(),
6758                metadata: HashMap::new(),
6759                api_response: self.api_response.clone(),
6760                execution_evidence,
6761            };
6762            Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6763        }
6764    }
6765
6766    #[async_trait]
6767    impl LlmInferenceEngine for FailingLlm {
6768        async fn infer(
6769            &self,
6770            _request: InferenceRequest,
6771        ) -> ferrum_types::Result<InferenceResponse> {
6772            Err(self.infer_failure.clone())
6773        }
6774
6775        async fn infer_stream(
6776            &self,
6777            request: InferenceRequest,
6778        ) -> ferrum_types::Result<
6779            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6780        > {
6781            if self.fail_after_stream_start {
6782                let _request_id = request.id;
6783                return Ok(Box::pin(stream::iter(vec![Err(self
6784                    .stream_chunk_failure
6785                    .clone())])));
6786            }
6787            Err(self.stream_start_failure.clone())
6788        }
6789    }
6790
6791    #[async_trait]
6792    impl LlmInferenceEngine for CapturingLlm {
6793        async fn infer(
6794            &self,
6795            request: InferenceRequest,
6796        ) -> ferrum_types::Result<InferenceResponse> {
6797            *self.last_request.lock().expect("capture lock") = Some(request.clone());
6798            Ok(InferenceResponse {
6799                request_id: request.id,
6800                text: "captured".to_string(),
6801                tokens: vec![TokenId::new(21)],
6802                finish_reason: FinishReason::Stop,
6803                usage: TokenUsage::new(9, 1),
6804                latency_ms: 1,
6805                created_at: chrono::Utc::now(),
6806                metadata: HashMap::new(),
6807                api_response: None,
6808                execution_evidence: None,
6809            })
6810        }
6811
6812        async fn infer_stream(
6813            &self,
6814            request: InferenceRequest,
6815        ) -> ferrum_types::Result<
6816            Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6817        > {
6818            *self.last_request.lock().expect("capture lock") = Some(request.clone());
6819            let chunk = StreamChunk {
6820                request_id: request.id,
6821                text: "captured".to_string(),
6822                token: Some(TokenId::new(21)),
6823                finish_reason: Some(FinishReason::Stop),
6824                usage: Some(TokenUsage::new(9, 1)),
6825                created_at: chrono::Utc::now(),
6826                metadata: HashMap::new(),
6827                api_response: None,
6828                execution_evidence: None,
6829            };
6830            Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6831        }
6832    }
6833
6834    fn state_with_stub(text: &str) -> AppState {
6835        AppState::default().with_llm(Arc::new(StubLlm::new(text)))
6836    }
6837
6838    fn router_with_stub(text: &str) -> Router {
6839        AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
6840    }
6841
6842    fn router_with_stub_and_template(text: &str, template: ModelChatTemplate) -> Router {
6843        AxumServer::from_llm(Arc::new(StubLlm::new(text)))
6844            .with_prompt_template(Some(template))
6845            .build_router()
6846    }
6847
6848    fn router_with_stub_and_request_dump_dir(text: &str, request_dump_dir: PathBuf) -> Router {
6849        AxumServer::from_state(
6850            AppState::default()
6851                .with_llm(Arc::new(StubLlm::new(text)))
6852                .with_request_dump_dir(Some(request_dump_dir)),
6853        )
6854        .build_router()
6855    }
6856
6857    fn router_with_stub_request_dump_and_profile(
6858        text: &str,
6859        request_dump_dir: PathBuf,
6860        profile_jsonl: PathBuf,
6861    ) -> Router {
6862        AxumServer::from_state(
6863            AppState::default()
6864                .with_llm(Arc::new(StubLlm::new(text)))
6865                .with_request_dump_dir(Some(request_dump_dir))
6866                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6867                .with_profile_jsonl(Some(profile_jsonl)),
6868        )
6869        .build_router()
6870    }
6871
6872    fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
6873        AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
6874    }
6875
6876    fn router_with_stub_finish_reason(text: &str, finish_reason: FinishReason) -> Router {
6877        AxumServer::from_llm(Arc::new(StubLlm {
6878            finish_reason,
6879            ..StubLlm::new(text)
6880        }))
6881        .build_router()
6882    }
6883
6884    fn router_with_stub_stream_chunks_and_request_dump_dir(
6885        chunks: &[&str],
6886        request_dump_dir: PathBuf,
6887    ) -> Router {
6888        AxumServer::from_state(
6889            AppState::default()
6890                .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6891                .with_request_dump_dir(Some(request_dump_dir)),
6892        )
6893        .build_router()
6894    }
6895
6896    fn router_with_stub_stream_request_dump_and_profile(
6897        chunks: &[&str],
6898        request_dump_dir: PathBuf,
6899        profile_jsonl: PathBuf,
6900    ) -> Router {
6901        AxumServer::from_state(
6902            AppState::default()
6903                .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6904                .with_request_dump_dir(Some(request_dump_dir))
6905                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6906                .with_profile_jsonl(Some(profile_jsonl)),
6907        )
6908        .build_router()
6909    }
6910
6911    fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
6912        AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
6913            .build_router()
6914    }
6915
6916    fn router_with_stub_api_response(
6917        text: &str,
6918        api_response: ferrum_types::ApiResponse,
6919    ) -> Router {
6920        AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
6921            .build_router()
6922    }
6923
6924    fn router_with_stub_api_response_and_finish_reason(
6925        text: &str,
6926        api_response: ferrum_types::ApiResponse,
6927        finish_reason: FinishReason,
6928    ) -> Router {
6929        AxumServer::from_llm(Arc::new(StubLlm::with_api_response_and_finish_reason(
6930            text,
6931            api_response,
6932            finish_reason,
6933        )))
6934        .build_router()
6935    }
6936
6937    fn weather_tool_api_response() -> ferrum_types::ApiResponse {
6938        ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6939            message: ferrum_types::ApiChatMessage {
6940                role: ferrum_types::ApiMessageRole::Assistant,
6941                content: String::new(),
6942                name: None,
6943                tool_calls: vec![ferrum_types::ApiToolCall {
6944                    id: "call_1".to_string(),
6945                    tool_type: "function".to_string(),
6946                    function: ferrum_types::ApiFunctionCall {
6947                        name: "weather".to_string(),
6948                        arguments: "{\"city\":\"Paris\"}".to_string(),
6949                    },
6950                }],
6951                tool_call_id: None,
6952                function_call: None,
6953            },
6954            finish_reason: Some("tool_calls".to_string()),
6955        })
6956    }
6957
6958    fn weather_tool_api_response_with_commentary() -> ferrum_types::ApiResponse {
6959        let mut response = weather_tool_api_response();
6960        let ferrum_types::ApiResponse::Chat(chat) = &mut response else {
6961            unreachable!("weather response is chat")
6962        };
6963        chat.message.content = "I will check.".to_string();
6964        response
6965    }
6966
6967    fn namespaced_tool_api_response() -> ferrum_types::ApiResponse {
6968        ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6969            message: ferrum_types::ApiChatMessage {
6970                role: ferrum_types::ApiMessageRole::Assistant,
6971                content: String::new(),
6972                name: None,
6973                tool_calls: vec![ferrum_types::ApiToolCall {
6974                    id: "call_ns_1".to_string(),
6975                    tool_type: "function".to_string(),
6976                    function: ferrum_types::ApiFunctionCall {
6977                        name: "collaboration__wait_agent".to_string(),
6978                        arguments: "{}".to_string(),
6979                    },
6980                }],
6981                tool_call_id: None,
6982                function_call: None,
6983            },
6984            finish_reason: Some("tool_calls".to_string()),
6985        })
6986    }
6987
6988    fn two_tool_api_response() -> ferrum_types::ApiResponse {
6989        ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6990            message: ferrum_types::ApiChatMessage {
6991                role: ferrum_types::ApiMessageRole::Assistant,
6992                content: String::new(),
6993                name: None,
6994                tool_calls: vec![
6995                    ferrum_types::ApiToolCall {
6996                        id: "call_1".to_string(),
6997                        tool_type: "function".to_string(),
6998                        function: ferrum_types::ApiFunctionCall {
6999                            name: "weather".to_string(),
7000                            arguments: "{}".to_string(),
7001                        },
7002                    },
7003                    ferrum_types::ApiToolCall {
7004                        id: "call_2".to_string(),
7005                        tool_type: "function".to_string(),
7006                        function: ferrum_types::ApiFunctionCall {
7007                            name: "clock".to_string(),
7008                            arguments: "{}".to_string(),
7009                        },
7010                    },
7011                ],
7012                tool_call_id: None,
7013                function_call: None,
7014            },
7015            finish_reason: Some("tool_calls".to_string()),
7016        })
7017    }
7018
7019    fn router_with_stub_without_stream_usage(text: &str) -> Router {
7020        AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
7021    }
7022
7023    fn router_without_llm() -> Router {
7024        AxumServer::from_state(AppState::default()).build_router()
7025    }
7026
7027    fn router_with_failing_llm() -> Router {
7028        AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
7029    }
7030
7031    fn router_with_failing_llm_and_request_dump_dir(request_dump_dir: PathBuf) -> Router {
7032        AxumServer::from_state(
7033            AppState::default()
7034                .with_llm(Arc::new(FailingLlm::new()))
7035                .with_request_dump_dir(Some(request_dump_dir)),
7036        )
7037        .build_router()
7038    }
7039
7040    fn router_with_failing_llm_request_dump_and_profile(
7041        request_dump_dir: PathBuf,
7042        profile_jsonl: PathBuf,
7043    ) -> Router {
7044        AxumServer::from_state(
7045            AppState::default()
7046                .with_llm(Arc::new(FailingLlm::new()))
7047                .with_request_dump_dir(Some(request_dump_dir))
7048                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
7049                .with_profile_jsonl(Some(profile_jsonl)),
7050        )
7051        .build_router()
7052    }
7053
7054    fn router_with_resource_exhausted_llm_and_request_dump_dir(
7055        request_dump_dir: PathBuf,
7056    ) -> Router {
7057        AxumServer::from_state(
7058            AppState::default()
7059                .with_llm(Arc::new(FailingLlm::resource_exhausted()))
7060                .with_request_dump_dir(Some(request_dump_dir)),
7061        )
7062        .build_router()
7063    }
7064
7065    fn router_with_stream_chunk_failing_llm() -> Router {
7066        AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
7067    }
7068
7069    fn router_with_stream_chunk_failing_llm_and_request_dump_dir(
7070        request_dump_dir: PathBuf,
7071    ) -> Router {
7072        AxumServer::from_state(
7073            AppState::default()
7074                .with_llm(Arc::new(FailingLlm::after_stream_start()))
7075                .with_request_dump_dir(Some(request_dump_dir)),
7076        )
7077        .build_router()
7078    }
7079
7080    fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
7081        let engine = Arc::new(CapturingLlm::new());
7082        let registry = ServedModelRegistry::try_new(
7083            "qwen3",
7084            ServedModelKind::Llm,
7085            vec![
7086                "qwen3".to_string(),
7087                "stub-model".to_string(),
7088                "served-alias".to_string(),
7089            ],
7090            vec![],
7091        )
7092        .unwrap();
7093        let router = AxumServer::from_llm(engine.clone())
7094            .with_served_model_registry(registry)
7095            .build_router();
7096        (router, engine)
7097    }
7098
7099    fn unique_request_dump_dir(test_name: &str) -> PathBuf {
7100        let path =
7101            std::env::temp_dir().join(format!("ferrum-server-{test_name}-{}", Uuid::new_v4()));
7102        fs::create_dir_all(&path).expect("create request dump dir");
7103        path
7104    }
7105
7106    fn unique_profile_jsonl(test_name: &str) -> PathBuf {
7107        std::env::temp_dir().join(format!(
7108            "ferrum-server-{test_name}-{}.jsonl",
7109            Uuid::new_v4()
7110        ))
7111    }
7112
7113    fn only_replay_bundle(root: &Path) -> PathBuf {
7114        let mut dirs = fs::read_dir(root)
7115            .expect("read request dump dir")
7116            .filter_map(|entry| {
7117                let path = entry.expect("dir entry").path();
7118                path.is_dir().then_some(path)
7119            })
7120            .collect::<Vec<_>>();
7121        dirs.sort();
7122        assert_eq!(
7123            dirs.len(),
7124            1,
7125            "expected exactly one replay bundle in {root:?}"
7126        );
7127        dirs.remove(0)
7128    }
7129
7130    fn read_json_file(path: impl AsRef<Path>) -> Value {
7131        let path = path.as_ref();
7132        let text = fs::read_to_string(path).unwrap_or_else(|err| {
7133            panic!("failed to read {}: {}", path.display(), err);
7134        });
7135        serde_json::from_str(&text).unwrap_or_else(|err| {
7136            panic!("failed to parse {}: {}", path.display(), err);
7137        })
7138    }
7139
7140    fn read_profile_events(path: &Path) -> Vec<Value> {
7141        let text = fs::read_to_string(path)
7142            .unwrap_or_else(|err| panic!("failed to read {}: {}", path.display(), err));
7143        text.lines()
7144            .filter(|line| !line.trim().is_empty())
7145            .map(|line| serde_json::from_str::<Value>(line).expect("profile event json"))
7146            .collect()
7147    }
7148
7149    fn assert_chat_failure_replay_bundle(
7150        root: &Path,
7151        expected_phase: &str,
7152        expected_error_kind: &str,
7153        expected_message: &str,
7154    ) {
7155        let bundle = only_replay_bundle(root);
7156        let request = read_json_file(bundle.join("request.json"));
7157        let request_id = request["request_id"]
7158            .as_str()
7159            .expect("request id")
7160            .to_string();
7161        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
7162        assert_eq!(bad_scan["request_id"], request_id);
7163        assert_eq!(bad_scan["failure_kind"], "error");
7164        assert_eq!(bad_scan["failure_phase"], expected_phase);
7165        assert_eq!(bad_scan["error_kind"], expected_error_kind);
7166
7167        let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
7168        assert_eq!(diagnostics["request_id"], request_id);
7169        assert_eq!(diagnostics["failure_kind"], "error");
7170        assert_eq!(diagnostics["first_failure_event"]["phase"], expected_phase);
7171        assert_eq!(
7172            diagnostics["first_failure_event"]["error_kind"],
7173            expected_error_kind
7174        );
7175        assert_eq!(diagnostics["nearest_request_id"], request_id);
7176        assert!(diagnostics["log_excerpt"]
7177            .as_str()
7178            .expect("log excerpt")
7179            .contains(expected_message));
7180        assert!(bundle.join("replay.command.json").is_file());
7181    }
7182
7183    fn assert_chat_success_replay_bundle(
7184        root: &Path,
7185        expected_token_ids: &[u32],
7186        expected_finish_reason: &str,
7187        expected_output_text: &str,
7188    ) {
7189        let bundle = only_replay_bundle(root);
7190        let request = read_json_file(bundle.join("request.json"));
7191        let request_id = request["request_id"]
7192            .as_str()
7193            .expect("request id")
7194            .to_string();
7195        let prompt_tokens = read_json_file(bundle.join("prompt_token_ids.json"));
7196        assert_eq!(prompt_tokens["request_id"], request_id);
7197        assert_eq!(prompt_tokens["token_ids"], json!([101, 202, 303]));
7198        assert_eq!(prompt_tokens["token_count"], 3);
7199        assert!(prompt_tokens["unavailable_reason"].is_null());
7200        let output_tokens = read_json_file(bundle.join("output_token_ids.json"));
7201        assert_eq!(output_tokens["request_id"], request_id);
7202        assert_eq!(output_tokens["token_ids"], json!(expected_token_ids));
7203        assert_eq!(output_tokens["token_count"], expected_token_ids.len());
7204        assert_eq!(output_tokens["finish_reason"], expected_finish_reason);
7205        assert!(output_tokens["unavailable_reason"].is_null());
7206
7207        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
7208        assert_eq!(bad_scan["request_id"], request_id);
7209        assert_eq!(bad_scan["bad_output"], false);
7210        assert_eq!(bad_scan["failure_kind"], serde_json::Value::Null);
7211        assert_eq!(
7212            bad_scan["output_chars"],
7213            expected_output_text.chars().count()
7214        );
7215        assert_eq!(
7216            bad_scan["classified_output_sha256"],
7217            sha256_hex(expected_output_text.as_bytes())
7218        );
7219
7220        let output_text_bytes = fs::read(bundle.join("output_text.txt")).unwrap();
7221        assert_eq!(bad_scan["output_sha256"], sha256_hex(&output_text_bytes));
7222        let output_text = String::from_utf8(output_text_bytes).unwrap();
7223        assert!(output_text.contains("[redacted actual output]"));
7224        assert!(output_text.contains(&format!(
7225            "sha256={}",
7226            sha256_hex(expected_output_text.as_bytes())
7227        )));
7228        assert!(output_text.contains(&format!("chars={}", expected_output_text.chars().count())));
7229
7230        let replay_body = read_json_file(bundle.join("replay_body.json"));
7231        assert_eq!(replay_body["messages"][0]["role"], "user");
7232        assert_eq!(replay_body["messages"][0]["content"], "[redacted]");
7233        assert_eq!(replay_body["messages"][0]["content_redacted"], true);
7234
7235        let replay = read_json_file(bundle.join("replay.command.json"));
7236        assert_eq!(replay["requires_running_server"], true);
7237        let argv = replay["argv"].as_array().expect("replay argv");
7238        assert!(argv.iter().any(|item| item == "--data-binary"));
7239        assert!(argv.iter().any(|item| {
7240            item.as_str()
7241                .is_some_and(|value| value.starts_with('@') && value.ends_with("replay_body.json"))
7242        }));
7243        assert_eq!(replay["engine_replay"]["requires_http_server"], false);
7244        let engine_argv = replay["engine_replay"]["argv"]
7245            .as_array()
7246            .expect("engine replay argv");
7247        assert!(engine_argv.iter().any(|item| item == "replay-bundle"));
7248    }
7249
7250    fn router_with_capturing_llm_and_template(
7251        template: ModelChatTemplate,
7252    ) -> (Router, Arc<CapturingLlm>) {
7253        router_with_capturing_llm_and_template_default(template, None)
7254    }
7255
7256    fn router_with_capturing_llm_and_template_default(
7257        template: ModelChatTemplate,
7258        default_enable_thinking: Option<bool>,
7259    ) -> (Router, Arc<CapturingLlm>) {
7260        let engine = Arc::new(CapturingLlm::new());
7261        let registry = ServedModelRegistry::try_new(
7262            "qwen3",
7263            ServedModelKind::Llm,
7264            vec!["served-alias".to_string()],
7265            vec![],
7266        )
7267        .unwrap();
7268        let router = AxumServer::from_llm(engine.clone())
7269            .with_served_model_registry(registry)
7270            .with_prompt_template(Some(template))
7271            .with_default_enable_thinking(default_enable_thinking)
7272            .build_router();
7273        (router, engine)
7274    }
7275
7276    fn qwen36_chat_template() -> ModelChatTemplate {
7277        ModelChatTemplate::new(
7278            include_str!("../tests/fixtures/chat_template/Qwen__Qwen3.6-35B-A3B/template.jinja"),
7279            "Qwen/Qwen3.6-35B-A3B",
7280        )
7281    }
7282
7283    async fn capture_qwen36_tool_history_request(
7284        reasoning_fields: Value,
7285        stream: bool,
7286    ) -> InferenceRequest {
7287        let mut assistant = json!({
7288            "role": "assistant",
7289            "content": null,
7290            "tool_calls": [{
7291                "id": "call_1",
7292                "type": "function",
7293                "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
7294            }]
7295        });
7296        assistant
7297            .as_object_mut()
7298            .expect("assistant message object")
7299            .extend(
7300                reasoning_fields
7301                    .as_object()
7302                    .expect("reasoning fields object")
7303                    .clone(),
7304            );
7305        let (router, engine) = router_with_capturing_llm_and_template(qwen36_chat_template());
7306        let response = post_json(
7307            router,
7308            "/v1/chat/completions",
7309            json!({
7310                "model": "served-alias",
7311                "messages": [
7312                    {"role": "user", "content": "Use the weather tool."},
7313                    assistant,
7314                    {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
7315                ],
7316                "tools": [{
7317                    "type": "function",
7318                    "function": {
7319                        "name": "weather",
7320                        "description": "Get weather",
7321                        "parameters": {
7322                            "type": "object",
7323                            "properties": {"city": {"type": "string"}},
7324                            "required": ["city"]
7325                        }
7326                    }
7327                }],
7328                "stream": stream
7329            }),
7330        )
7331        .await;
7332        assert_eq!(response.status(), AxumStatusCode::OK);
7333        if stream {
7334            assert!(response_text(response).await.contains("[DONE]"));
7335        }
7336        engine.last_request()
7337    }
7338
7339    fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
7340        let engine = Arc::new(CapturingLlm::new());
7341        let router = AxumServer::from_llm(engine.clone())
7342            .with_lora_adapters(
7343                "qwen3",
7344                vec![LoraAdapterModel::new(
7345                    "sql",
7346                    "qwen3:sql",
7347                    "/tmp/sql-adapter",
7348                )],
7349            )
7350            .unwrap()
7351            .build_router();
7352        (router, engine)
7353    }
7354
7355    fn router_with_stub_embed() -> Router {
7356        AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
7357    }
7358
7359    fn router_with_stub_transcribe() -> Router {
7360        AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
7361    }
7362
7363    fn router_with_stub_tts() -> Router {
7364        AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
7365    }
7366
7367    async fn post_json(app: Router, path: &str, body: Value) -> Response {
7368        app.oneshot(
7369            Request::builder()
7370                .method("POST")
7371                .uri(path)
7372                .header(header::CONTENT_TYPE, "application/json")
7373                .body(Body::from(body.to_string()))
7374                .expect("request"),
7375        )
7376        .await
7377        .expect("route response")
7378    }
7379
7380    async fn post_json_with_benchmark_correlation(
7381        app: Router,
7382        path: &str,
7383        body: Value,
7384        correlation: &BenchmarkRequestCorrelation,
7385    ) -> Response {
7386        app.oneshot(
7387            Request::builder()
7388                .method("POST")
7389                .uri(path)
7390                .header(header::CONTENT_TYPE, "application/json")
7391                .header(BENCHMARK_RUN_ID_HEADER, &correlation.benchmark_run_id)
7392                .header(BENCHMARK_CELL_ID_HEADER, &correlation.cell_id)
7393                .header(
7394                    BENCHMARK_REPEAT_INDEX_HEADER,
7395                    correlation.repeat_index.to_string(),
7396                )
7397                .header(BENCHMARK_PHASE_HEADER, correlation.phase.as_str())
7398                .header(
7399                    BENCHMARK_REQUEST_INDEX_HEADER,
7400                    correlation.request_index.to_string(),
7401                )
7402                .body(Body::from(body.to_string()))
7403                .expect("request"),
7404        )
7405        .await
7406        .expect("route response")
7407    }
7408
7409    async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
7410        app.oneshot(
7411            Request::builder()
7412                .method("POST")
7413                .uri(path)
7414                .header(header::CONTENT_TYPE, "application/json")
7415                .body(Body::from(body.to_string()))
7416                .expect("request"),
7417        )
7418        .await
7419        .expect("route response")
7420    }
7421
7422    async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
7423        app.oneshot(
7424            Request::builder()
7425                .method("POST")
7426                .uri(path)
7427                .header(
7428                    header::CONTENT_TYPE,
7429                    format!("multipart/form-data; boundary={boundary}"),
7430                )
7431                .body(Body::from(body.to_string()))
7432                .expect("request"),
7433        )
7434        .await
7435        .expect("route response")
7436    }
7437
7438    async fn get(app: Router, path: &str) -> Response {
7439        app.oneshot(
7440            Request::builder()
7441                .method("GET")
7442                .uri(path)
7443                .body(Body::empty())
7444                .expect("request"),
7445        )
7446        .await
7447        .expect("route response")
7448    }
7449
7450    async fn response_json(response: Response) -> Value {
7451        let bytes = to_bytes(response.into_body(), usize::MAX)
7452            .await
7453            .expect("body bytes");
7454        serde_json::from_slice(&bytes).expect("json body")
7455    }
7456
7457    async fn response_text(response: Response) -> String {
7458        let bytes = to_bytes(response.into_body(), usize::MAX)
7459            .await
7460            .expect("body bytes");
7461        String::from_utf8(bytes.to_vec()).expect("utf8 body")
7462    }
7463
7464    fn responses_sse_json_events(body: &str) -> Vec<Value> {
7465        body.lines()
7466            .filter_map(|line| line.strip_prefix("data: "))
7467            .filter(|data| *data != "[DONE]")
7468            .map(|data| serde_json::from_str(data).expect("Responses SSE JSON event"))
7469            .collect()
7470    }
7471
7472    async fn response_bytes(response: Response) -> Vec<u8> {
7473        to_bytes(response.into_body(), usize::MAX)
7474            .await
7475            .expect("body bytes")
7476            .to_vec()
7477    }
7478
7479    async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
7480        let response = error.into_response();
7481        let status = response.status();
7482        (status, response_json(response).await)
7483    }
7484
7485    fn assert_openai_stream_error(body: &str, expected_message: &str) {
7486        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7487        assert!(
7488            body.contains("\"error\":{\"message\":\""),
7489            "stream failure should emit OpenAI error envelope: {body}"
7490        );
7491        assert!(
7492            body.contains(expected_message),
7493            "stream failure should include engine error message {expected_message:?}: {body}"
7494        );
7495        assert!(
7496            body.contains("\"type\":\"internal_server_error\""),
7497            "stream failure should use internal_server_error: {body}"
7498        );
7499        assert!(
7500            !body.contains("{\"error\":\""),
7501            "stream failure must not use legacy bare error payload: {body}"
7502        );
7503    }
7504
7505    fn chat_request(extra: Value) -> ChatCompletionsRequest {
7506        let mut value = json!({
7507            "model": "stub-model",
7508            "messages": [{"role": "user", "content": "hello"}],
7509            "max_tokens": 8
7510        });
7511        let obj = value.as_object_mut().unwrap();
7512        for (k, v) in extra.as_object().unwrap() {
7513            obj.insert(k.clone(), v.clone());
7514        }
7515        serde_json::from_value(value).expect("chat request")
7516    }
7517
7518    #[tokio::test]
7519    async fn responses_route_returns_sync_text_and_usage() {
7520        let response = post_json(
7521            router_with_stub("hello from ferrum"),
7522            "/v1/responses",
7523            json!({
7524                "model": "stub-model",
7525                "input": "hello",
7526                "store": false
7527            }),
7528        )
7529        .await;
7530        assert_eq!(response.status(), AxumStatusCode::OK);
7531        let body = response_json(response).await;
7532        assert_eq!(body["object"], "response");
7533        assert_eq!(body["status"], "completed");
7534        assert_eq!(body["store"], false);
7535        assert_eq!(body["output"][0]["type"], "message");
7536        assert_eq!(body["output"][0]["phase"], "final_answer");
7537        assert_eq!(body["output"][0]["content"][0]["text"], "hello from ferrum");
7538        assert_eq!(body["usage"]["input_tokens"], 7);
7539        assert_eq!(body["usage"]["output_tokens"], 2);
7540        assert_eq!(body["usage"]["total_tokens"], 9);
7541        assert_eq!(body["presence_penalty"], 0.0);
7542        assert_eq!(body["frequency_penalty"], 0.0);
7543    }
7544
7545    #[tokio::test]
7546    async fn responses_route_streams_ordered_text_events_once() {
7547        let response = post_json(
7548            router_with_stub_stream_chunks(&["he", "llo"]),
7549            "/v1/responses",
7550            json!({
7551                "model": "stub-model",
7552                "input": [{"role": "user", "content": "say hello"}],
7553                "stream": true
7554            }),
7555        )
7556        .await;
7557        assert_eq!(response.status(), AxumStatusCode::OK);
7558        let body = response_text(response).await;
7559        for event in [
7560            "response.created",
7561            "response.output_item.added",
7562            "response.output_text.delta",
7563            "response.output_text.done",
7564            "response.output_item.done",
7565            "response.completed",
7566        ] {
7567            assert!(
7568                body.contains(&format!("event: {event}")),
7569                "missing {event}: {body}"
7570            );
7571        }
7572        assert_eq!(
7573            body.matches("event: response.completed").count(),
7574            1,
7575            "completed must be emitted exactly once: {body}"
7576        );
7577        assert!(
7578            body.contains("\"delta\":\"he\""),
7579            "missing first delta: {body}"
7580        );
7581        assert!(
7582            body.contains("\"delta\":\"llo\""),
7583            "missing second delta: {body}"
7584        );
7585        assert!(body.contains("\"input_tokens\":5"), "missing usage: {body}");
7586        let events = responses_sse_json_events(&body);
7587        let message_added = events
7588            .iter()
7589            .find(|event| {
7590                event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7591            })
7592            .expect("message item added");
7593        assert!(
7594            message_added["item"].get("phase").is_none(),
7595            "stream must not guess phase before later tool calls are known: {body}"
7596        );
7597        let message_done = events
7598            .iter()
7599            .find(|event| {
7600                event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7601            })
7602            .expect("message item done");
7603        assert_eq!(message_done["item"]["phase"], "final_answer");
7604        let terminal = events
7605            .iter()
7606            .find(|event| event["type"] == "response.completed")
7607            .expect("completed response");
7608        assert_eq!(terminal["response"]["output"][0]["phase"], "final_answer");
7609        let completed = body
7610            .find("event: response.completed")
7611            .expect("completed event");
7612        let done = body.find("data: [DONE]").expect("terminal DONE marker");
7613        assert!(
7614            completed < done,
7615            "DONE must follow response.completed: {body}"
7616        );
7617    }
7618
7619    #[tokio::test]
7620    async fn responses_route_supports_stateless_function_round_trip() {
7621        let tool = json!({
7622            "type": "function",
7623            "name": "weather",
7624            "description": "Get weather",
7625            "parameters": {
7626                "type": "object",
7627                "properties": {"city": {"type": "string"}},
7628                "required": ["city"]
7629            }
7630        });
7631        let first = post_json(
7632            router_with_stub_api_response("", weather_tool_api_response()),
7633            "/v1/responses",
7634            json!({
7635                "model": "stub-model",
7636                "input": "Use the weather tool",
7637                "tools": [tool.clone()],
7638                "tool_choice": "auto"
7639            }),
7640        )
7641        .await;
7642        assert_eq!(first.status(), AxumStatusCode::OK);
7643        let first_body = response_json(first).await;
7644        let call = first_body["output"][0].clone();
7645        assert_eq!(call["type"], "function_call");
7646        assert_eq!(call["call_id"], "call_1");
7647        assert_eq!(call["name"], "weather");
7648        assert_eq!(call["arguments"], "{\"city\":\"Paris\"}");
7649
7650        let second = post_json(
7651            router_with_stub("weather received"),
7652            "/v1/responses",
7653            json!({
7654                "model": "stub-model",
7655                "input": [
7656                    {"role": "user", "content": "Use the weather tool"},
7657                    call,
7658                    {"type": "function_call_output", "call_id": "call_1", "output": "sunny"}
7659                ],
7660                "tools": [tool]
7661            }),
7662        )
7663        .await;
7664        assert_eq!(second.status(), AxumStatusCode::OK);
7665        let second_body = response_json(second).await;
7666        assert_eq!(
7667            second_body["output"][0]["content"][0]["text"],
7668            "weather received"
7669        );
7670    }
7671
7672    #[tokio::test]
7673    async fn responses_route_marks_text_before_calls_as_commentary() {
7674        let request = || {
7675            json!({
7676                "model": "stub-model",
7677                "input": "Use the weather tool",
7678                "stream": false,
7679                "tools": [{
7680                    "type": "function",
7681                    "name": "weather",
7682                    "parameters": {"type": "object"}
7683                }]
7684            })
7685        };
7686        let sync = post_json(
7687            router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7688            "/v1/responses",
7689            request(),
7690        )
7691        .await;
7692        assert_eq!(sync.status(), AxumStatusCode::OK);
7693        let sync = response_json(sync).await;
7694        assert_eq!(sync["output"][0]["type"], "message");
7695        assert_eq!(sync["output"][0]["phase"], "commentary");
7696        assert_eq!(sync["output"][1]["type"], "function_call");
7697
7698        let mut stream_request = request();
7699        stream_request["stream"] = json!(true);
7700        let stream = post_json(
7701            router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7702            "/v1/responses",
7703            stream_request,
7704        )
7705        .await;
7706        assert_eq!(stream.status(), AxumStatusCode::OK);
7707        let body = response_text(stream).await;
7708        let events = responses_sse_json_events(&body);
7709        let message_added = events
7710            .iter()
7711            .find(|event| {
7712                event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7713            })
7714            .expect("message item added");
7715        assert!(message_added["item"].get("phase").is_none());
7716        let message_done = events
7717            .iter()
7718            .find(|event| {
7719                event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7720            })
7721            .expect("message item done");
7722        assert_eq!(message_done["item"]["phase"], "commentary");
7723        let terminal = events
7724            .iter()
7725            .find(|event| event["type"] == "response.completed")
7726            .expect("completed response");
7727        assert_eq!(terminal["response"]["output"][0]["phase"], "commentary");
7728        assert_eq!(terminal["response"]["output"][1]["type"], "function_call");
7729    }
7730
7731    #[tokio::test]
7732    async fn responses_route_accepts_real_caller_owned_second_turn_shape() {
7733        let response = post_json(
7734            router_with_stub("You first said hello."),
7735            "/v1/responses",
7736            json!({
7737                "model": "stub-model",
7738                "instructions": "Answer from the supplied history.",
7739                "input": [
7740                    {
7741                        "type": "message",
7742                        "role": "user",
7743                        "content": [{"type": "input_text", "text": "Hello"}]
7744                    },
7745                    {
7746                        "type": "message",
7747                        "role": "assistant",
7748                        "content": [{"type": "output_text", "text": "Hi there!"}]
7749                    },
7750                    {
7751                        "type": "reasoning",
7752                        "encrypted_content": null,
7753                        "summary": []
7754                    },
7755                    {
7756                        "type": "message",
7757                        "role": "user",
7758                        "content": [{"type": "input_text", "text": "What did I say first?"}]
7759                    }
7760                ],
7761                "store": false,
7762                "stream": false,
7763                "include": ["reasoning.encrypted_content"],
7764                "parallel_tool_calls": false,
7765                "prompt_cache_key": "thread-1",
7766                "reasoning": {"effort": "high", "summary": "auto"}
7767            }),
7768        )
7769        .await;
7770        assert_eq!(response.status(), AxumStatusCode::OK);
7771        let body = response_json(response).await;
7772        assert_eq!(
7773            body["output"][0]["content"][0]["text"],
7774            "You first said hello."
7775        );
7776        assert_eq!(body["parallel_tool_calls"], false);
7777        assert_eq!(body["prompt_cache_key"], "thread-1");
7778        assert_eq!(body["reasoning"]["effort"], "high");
7779    }
7780
7781    #[tokio::test]
7782    async fn responses_route_merges_instructions_with_leading_developer_message() {
7783        let template = ModelChatTemplate::new(
7784            "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7785            "strict-leading-system-template",
7786        );
7787        let response = post_json(
7788            router_with_stub_and_template("ok", template),
7789            "/v1/responses",
7790            json!({
7791                "model": "stub-model",
7792                "instructions": "Top-level instructions",
7793                "input": [
7794                    {"type": "message", "role": "developer", "content": "Developer instructions"},
7795                    {"type": "message", "role": "user", "content": "Hello"}
7796                ]
7797            }),
7798        )
7799        .await;
7800        assert_eq!(response.status(), AxumStatusCode::OK);
7801    }
7802
7803    #[tokio::test]
7804    async fn responses_route_adapts_interleaved_system_for_strict_template() {
7805        let template = ModelChatTemplate::new(
7806            "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}{% endfor %}{% if messages|length != 2 %}{{ raise_exception('expected two messages') }}{% endif %}{% if messages[0].role != 'system' %}{{ raise_exception('system must be first') }}{% endif %}{% if messages[0].content != 'Initial instructions\\n\\nDeferred tool instructions' %}{{ raise_exception('system instructions were not preserved') }}{% endif %}[{{ messages[0].role }}]{{ messages[0].content }}[{{ messages[1].role }}]{{ messages[1].content }}",
7807            "strict-leading-system-template",
7808        );
7809        let response = post_json(
7810            router_with_stub_and_template("ok", template),
7811            "/v1/responses",
7812            json!({
7813                "model": "stub-model",
7814                "input": [
7815                    {"role": "system", "content": "Initial instructions"},
7816                    {"role": "user", "content": "Use the available tool"},
7817                    {"role": "developer", "content": "Deferred tool instructions"}
7818                ]
7819            }),
7820        )
7821        .await;
7822        assert_eq!(response.status(), AxumStatusCode::OK);
7823    }
7824
7825    #[tokio::test]
7826    async fn responses_route_keeps_phase_aligned_through_system_injection() {
7827        let template = ModelChatTemplate::new(
7828            "{% if messages|length != 3 %}{{ raise_exception('expected three messages') }}{% endif %}{% if messages[0].role != 'system' %}{{ raise_exception('system must be first') }}{% endif %}{% if messages[1].role != 'assistant' or messages[1].phase != 'commentary' %}{{ raise_exception('assistant phase was not preserved') }}{% endif %}{% if messages[2].role != 'user' or messages[2].phase is defined %}{{ raise_exception('phase metadata shifted') }}{% endif %}[assistant]",
7829            "phase-alignment-template",
7830        );
7831        let response = post_json(
7832            router_with_stub_and_template(r#"{"ok":true}"#, template),
7833            "/v1/responses",
7834            json!({
7835                "model": "stub-model",
7836                "instructions": "Top-level instructions",
7837                "input": [
7838                    {
7839                        "type": "message",
7840                        "role": "assistant",
7841                        "phase": "commentary",
7842                        "content": "I will inspect."
7843                    },
7844                    {"type": "message", "role": "user", "content": "Continue"}
7845                ],
7846                "text": {"format": {"type": "json_object"}}
7847            }),
7848        )
7849        .await;
7850        assert_eq!(response.status(), AxumStatusCode::OK);
7851    }
7852
7853    #[tokio::test]
7854    async fn responses_route_can_disable_interleaved_system_coalescing() {
7855        let template = ModelChatTemplate::new(
7856            "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7857            "strict-leading-system-template",
7858        );
7859        let mut engine = CapturingLlm::new();
7860        engine.config.model.model_id = ModelId::new("stub-model");
7861        let engine = Arc::new(engine);
7862        let router = AxumServer::from_state(
7863            AppState::default()
7864                .with_llm(engine.clone())
7865                .with_prompt_template(Some(template))
7866                .with_interleaved_system_coalescing(false),
7867        )
7868        .build_router();
7869        let response = post_json(
7870            router,
7871            "/v1/responses",
7872            json!({
7873                "model": "stub-model",
7874                "input": [
7875                    {"role": "system", "content": "Initial instructions"},
7876                    {"role": "user", "content": "Use the available tool"},
7877                    {"role": "developer", "content": "Deferred tool instructions"}
7878                ]
7879            }),
7880        )
7881        .await;
7882        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7883        let body = response_json(response).await;
7884        assert_eq!(body["error"]["type"], "invalid_request_error");
7885        assert!(!engine.has_captured_request());
7886        assert!(
7887            body.to_string()
7888                .contains("System message must be at the beginning."),
7889            "{body}"
7890        );
7891    }
7892
7893    #[tokio::test]
7894    async fn chat_route_applies_and_can_disable_interleaved_system_coalescing() {
7895        let template = || {
7896            ModelChatTemplate::new(
7897                "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7898                "strict-leading-system-template",
7899            )
7900        };
7901        let request = || {
7902            json!({
7903                "model": "stub-model",
7904                "messages": [
7905                    {"role": "system", "content": "Initial instructions"},
7906                    {"role": "user", "content": "Use the available tool"},
7907                    {"role": "system", "content": "Deferred tool instructions"}
7908                ]
7909            })
7910        };
7911
7912        let enabled = post_json(
7913            router_with_stub_and_template("ok", template()),
7914            "/v1/chat/completions",
7915            request(),
7916        )
7917        .await;
7918        assert_eq!(enabled.status(), AxumStatusCode::OK);
7919
7920        let consecutive = post_json(
7921            router_with_stub_and_template("ok", template()),
7922            "/v1/chat/completions",
7923            json!({
7924                "model": "stub-model",
7925                "messages": [
7926                    {"role": "system", "content": "Initial instructions"},
7927                    {"role": "system", "content": "Deferred tool instructions"},
7928                    {"role": "user", "content": "Use the available tool"}
7929                ]
7930            }),
7931        )
7932        .await;
7933        assert_eq!(consecutive.status(), AxumStatusCode::OK);
7934
7935        let mut engine = CapturingLlm::new();
7936        engine.config.model.model_id = ModelId::new("stub-model");
7937        let engine = Arc::new(engine);
7938        let disabled_router = AxumServer::from_state(
7939            AppState::default()
7940                .with_llm(engine.clone())
7941                .with_prompt_template(Some(template()))
7942                .with_interleaved_system_coalescing(false),
7943        )
7944        .build_router();
7945        let disabled = post_json(disabled_router, "/v1/chat/completions", request()).await;
7946        assert_eq!(disabled.status(), AxumStatusCode::BAD_REQUEST);
7947        let body = response_json(disabled).await;
7948        assert_eq!(body["error"]["type"], "invalid_request_error");
7949        assert!(!engine.has_captured_request());
7950        assert!(
7951            body.to_string()
7952                .contains("System message must be at the beginning."),
7953            "{body}"
7954        );
7955    }
7956
7957    #[tokio::test]
7958    async fn responses_route_keeps_structured_output_to_one_leading_system_message() {
7959        let template = ModelChatTemplate::new(
7960            "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7961            "strict-leading-system-template",
7962        );
7963        let response = post_json(
7964            router_with_stub_and_template(r#"{"ok":true}"#, template),
7965            "/v1/responses",
7966            json!({
7967                "model": "stub-model",
7968                "instructions": "Top-level instructions",
7969                "input": [
7970                    {"type": "message", "role": "developer", "content": "Developer instructions"},
7971                    {"type": "message", "role": "user", "content": "Return JSON"}
7972                ],
7973                "text": {"format": {"type": "json_object"}}
7974            }),
7975        )
7976        .await;
7977        assert_eq!(response.status(), AxumStatusCode::OK);
7978        let body = response_json(response).await;
7979        assert_eq!(body["output"][0]["content"][0]["text"], r#"{"ok":true}"#);
7980    }
7981
7982    #[tokio::test]
7983    async fn responses_route_output_can_be_replayed_with_readable_reasoning() {
7984        let first = post_json(
7985            router_with_stub("<think>Checked the supplied facts.</think>\nFirst answer"),
7986            "/v1/responses",
7987            json!({
7988                "model": "stub-model",
7989                "input": "First question",
7990                "include": ["reasoning.encrypted_content"]
7991            }),
7992        )
7993        .await;
7994        assert_eq!(first.status(), AxumStatusCode::OK);
7995        let first_body = response_json(first).await;
7996        assert_eq!(first_body["output"][0]["type"], "reasoning");
7997        assert_eq!(
7998            first_body["output"][0]["content"][0]["text"],
7999            "Checked the supplied facts."
8000        );
8001        assert_eq!(first_body["output"][0]["encrypted_content"], Value::Null);
8002        assert_eq!(first_body["output"][1]["type"], "message");
8003
8004        let mut input = vec![json!({
8005            "type": "message",
8006            "role": "user",
8007            "content": [{"type": "input_text", "text": "First question"}]
8008        })];
8009        input.extend(first_body["output"].as_array().unwrap().iter().cloned());
8010        input.push(json!({
8011            "type": "message",
8012            "role": "user",
8013            "content": [{"type": "input_text", "text": "Continue"}]
8014        }));
8015        let second = post_json(
8016            router_with_stub("Second answer"),
8017            "/v1/responses",
8018            json!({"model": "stub-model", "input": input}),
8019        )
8020        .await;
8021        assert_eq!(second.status(), AxumStatusCode::OK);
8022        let second_body = response_json(second).await;
8023        assert_eq!(
8024            second_body["output"][0]["content"][0]["text"],
8025            "Second answer"
8026        );
8027    }
8028
8029    #[tokio::test]
8030    async fn responses_route_streams_reasoning_before_text_with_stable_indices() {
8031        let response = post_json(
8032            router_with_stub_stream_chunks(&["<think>inspect", " history</think>\nfinal"]),
8033            "/v1/responses",
8034            json!({
8035                "model": "stub-model",
8036                "input": "answer",
8037                "stream": true,
8038                "include": ["reasoning.encrypted_content"]
8039            }),
8040        )
8041        .await;
8042        assert_eq!(response.status(), AxumStatusCode::OK);
8043        let body = response_text(response).await;
8044        let events = responses_sse_json_events(&body);
8045        for (sequence, event) in events.iter().enumerate() {
8046            assert_eq!(
8047                event["sequence_number"], sequence,
8048                "Responses sequence numbers must be contiguous: {body}"
8049            );
8050        }
8051        for event in [
8052            "response.reasoning_text.delta",
8053            "response.reasoning_text.done",
8054            "response.output_text.delta",
8055            "response.completed",
8056        ] {
8057            assert!(
8058                body.contains(&format!("event: {event}")),
8059                "missing {event}: {body}"
8060            );
8061        }
8062        let reasoning_done = body
8063            .find("event: response.reasoning_text.done")
8064            .expect("reasoning done");
8065        let text_added = body[reasoning_done..]
8066            .find("event: response.output_item.added")
8067            .map(|offset| reasoning_done + offset)
8068            .expect("text item added");
8069        assert!(
8070            reasoning_done < text_added,
8071            "reasoning must finish before text: {body}"
8072        );
8073        assert!(
8074            body.contains("\"output_index\":0,\"content_index\":0,\"delta\":\"inspect"),
8075            "reasoning must use output index 0: {body}"
8076        );
8077        assert!(
8078            body.contains("\"output_index\":1,\"content_index\":0,\"delta\":\"final"),
8079            "text must use output index 1: {body}"
8080        );
8081        let reasoning_added = events
8082            .iter()
8083            .find(|event| {
8084                event["type"] == "response.output_item.added"
8085                    && event["item"]["type"] == "reasoning"
8086            })
8087            .expect("reasoning item added");
8088        assert_eq!(reasoning_added["item"]["status"], "in_progress");
8089        let reasoning_part_added = events
8090            .iter()
8091            .find(|event| {
8092                event["type"] == "response.content_part.added"
8093                    && event["part"]["type"] == "reasoning_text"
8094            })
8095            .expect("reasoning content part added");
8096        assert_eq!(reasoning_part_added["output_index"], 0);
8097        let reasoning_item_done = events
8098            .iter()
8099            .find(|event| {
8100                event["type"] == "response.output_item.done" && event["item"]["type"] == "reasoning"
8101            })
8102            .expect("reasoning item done");
8103        assert_eq!(reasoning_item_done["item"]["status"], "completed");
8104        let terminal = events
8105            .iter()
8106            .find(|event| event["type"] == "response.completed")
8107            .expect("terminal response");
8108        assert_eq!(
8109            terminal["response"]["output"][0],
8110            reasoning_item_done["item"]
8111        );
8112        assert!(
8113            body.contains("data: [DONE]"),
8114            "missing terminal marker: {body}"
8115        );
8116    }
8117
8118    #[tokio::test]
8119    async fn responses_route_streams_function_call_events() {
8120        let response = post_json(
8121            router_with_stub_api_response("", weather_tool_api_response()),
8122            "/v1/responses",
8123            json!({
8124                "model": "stub-model",
8125                "input": "Use the weather tool",
8126                "stream": true,
8127                "tools": [{
8128                    "type": "function",
8129                    "name": "weather",
8130                    "parameters": {"type": "object"}
8131                }]
8132            }),
8133        )
8134        .await;
8135        assert_eq!(response.status(), AxumStatusCode::OK);
8136        let body = response_text(response).await;
8137        assert!(
8138            body.contains("event: response.function_call_arguments.delta"),
8139            "missing function delta: {body}"
8140        );
8141        assert!(
8142            body.contains("event: response.function_call_arguments.done"),
8143            "missing function done: {body}"
8144        );
8145        assert!(
8146            body.contains("\"call_id\":\"call_1\""),
8147            "missing call id: {body}"
8148        );
8149        assert_eq!(body.matches("event: response.completed").count(), 1);
8150    }
8151
8152    #[tokio::test]
8153    async fn responses_route_round_trips_namespace_identity_without_leaking_chat_alias() {
8154        let namespace_tool = json!({
8155            "type": "namespace",
8156            "name": "collaboration",
8157            "description": "Agent coordination tools",
8158            "tools": [{
8159                "type": "function",
8160                "name": "wait_agent",
8161                "parameters": {"type": "object"}
8162            }]
8163        });
8164        let sync = post_json(
8165            router_with_stub_api_response("", namespaced_tool_api_response()),
8166            "/v1/responses",
8167            json!({
8168                "model": "stub-model",
8169                "input": "Wait for the agent",
8170                "tools": [namespace_tool.clone()]
8171            }),
8172        )
8173        .await;
8174        assert_eq!(sync.status(), AxumStatusCode::OK);
8175        let sync_body = response_json(sync).await;
8176        assert_eq!(sync_body["output"][0]["type"], "function_call");
8177        assert_eq!(sync_body["output"][0]["namespace"], "collaboration");
8178        assert_eq!(sync_body["output"][0]["name"], "wait_agent");
8179
8180        let stream = post_json(
8181            router_with_stub_api_response("", namespaced_tool_api_response()),
8182            "/v1/responses",
8183            json!({
8184                "model": "stub-model",
8185                "input": "Wait for the agent",
8186                "tools": [namespace_tool],
8187                "stream": true
8188            }),
8189        )
8190        .await;
8191        assert_eq!(stream.status(), AxumStatusCode::OK);
8192        let stream_body = response_text(stream).await;
8193        let events = responses_sse_json_events(&stream_body);
8194        let function_events = events
8195            .iter()
8196            .filter(|event| {
8197                event["item"]["type"] == "function_call"
8198                    || event["type"] == "response.function_call_arguments.done"
8199            })
8200            .collect::<Vec<_>>();
8201        assert!(!function_events.is_empty());
8202        for event in function_events {
8203            let value = event.get("item").unwrap_or(event);
8204            assert_eq!(value["namespace"], "collaboration");
8205            assert_eq!(value["name"], "wait_agent");
8206        }
8207        assert!(stream_body.contains("data: [DONE]"));
8208        assert!(!stream_body.contains("collaboration__wait_agent"));
8209    }
8210
8211    #[tokio::test]
8212    async fn responses_route_enforces_parallel_tool_call_constraint() {
8213        let tools = json!([
8214            {"type": "function", "name": "weather", "parameters": {"type": "object"}},
8215            {"type": "function", "name": "clock", "parameters": {"type": "object"}}
8216        ]);
8217        let sync = post_json(
8218            router_with_stub_api_response("", two_tool_api_response()),
8219            "/v1/responses",
8220            json!({
8221                "model": "stub-model",
8222                "input": "Use both tools",
8223                "tools": tools.clone(),
8224                "parallel_tool_calls": false
8225            }),
8226        )
8227        .await;
8228        assert_eq!(sync.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
8229
8230        let stream = post_json(
8231            router_with_stub_api_response("", two_tool_api_response()),
8232            "/v1/responses",
8233            json!({
8234                "model": "stub-model",
8235                "input": "Use both tools",
8236                "tools": tools,
8237                "parallel_tool_calls": false,
8238                "stream": true
8239            }),
8240        )
8241        .await;
8242        assert_eq!(stream.status(), AxumStatusCode::OK);
8243        let body = response_text(stream).await;
8244        assert!(
8245            body.contains("event: response.failed"),
8246            "missing failure: {body}"
8247        );
8248        assert!(
8249            !body.contains("event: response.completed"),
8250            "must not complete: {body}"
8251        );
8252        assert!(
8253            body.contains("data: [DONE]"),
8254            "missing terminal marker: {body}"
8255        );
8256    }
8257
8258    #[tokio::test]
8259    async fn responses_route_streams_incomplete_terminal_event() {
8260        let response = post_json(
8261            router_with_stub_finish_reason("partial", FinishReason::Length),
8262            "/v1/responses",
8263            json!({"model": "stub-model", "input": "answer", "stream": true}),
8264        )
8265        .await;
8266        assert_eq!(response.status(), AxumStatusCode::OK);
8267        let body = response_text(response).await;
8268        assert!(
8269            body.contains("event: response.incomplete"),
8270            "missing incomplete terminal event: {body}"
8271        );
8272        assert!(
8273            !body.contains("event: response.completed"),
8274            "incomplete response must not emit completed: {body}"
8275        );
8276        assert!(
8277            body.contains("data: [DONE]"),
8278            "missing terminal marker: {body}"
8279        );
8280        let events = responses_sse_json_events(&body);
8281        let output_done = events
8282            .iter()
8283            .find(|event| event["type"] == "response.output_item.done")
8284            .expect("incomplete output item done event");
8285        assert_eq!(output_done["item"]["status"], "incomplete");
8286        let terminal = events
8287            .iter()
8288            .find(|event| event["type"] == "response.incomplete")
8289            .expect("incomplete terminal event");
8290        assert_eq!(terminal["response"]["output"][0]["status"], "incomplete");
8291    }
8292
8293    #[tokio::test]
8294    async fn responses_route_marks_sync_length_output_incomplete() {
8295        let response = post_json(
8296            router_with_stub_finish_reason("partial", FinishReason::Length),
8297            "/v1/responses",
8298            json!({"model": "stub-model", "input": "answer"}),
8299        )
8300        .await;
8301        assert_eq!(response.status(), AxumStatusCode::OK);
8302        let body = response_json(response).await;
8303        assert_eq!(body["status"], "incomplete");
8304        assert_eq!(body["output"][0]["status"], "incomplete");
8305    }
8306
8307    #[tokio::test]
8308    async fn responses_route_rejects_state_and_non_function_tools() {
8309        for (extra, param) in [
8310            (json!({"store": true}), "store"),
8311            (
8312                json!({"previous_response_id": "resp_previous"}),
8313                "previous_response_id",
8314            ),
8315            (
8316                json!({"tools": [{"type": "mcp", "server_label": "docs"}]}),
8317                "tools[0].type",
8318            ),
8319        ] {
8320            let mut body = json!({"model": "stub-model", "input": "hello"});
8321            body.as_object_mut()
8322                .unwrap()
8323                .extend(extra.as_object().unwrap().clone());
8324            let response = post_json(router_with_stub("unused"), "/v1/responses", body).await;
8325            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8326            let error = response_json(response).await;
8327            assert_eq!(error["error"]["param"], param, "error: {error}");
8328        }
8329    }
8330
8331    #[tokio::test]
8332    async fn responses_mvp_keeps_chat_completions_route_working() {
8333        let response = post_json(
8334            router_with_stub("chat still works"),
8335            "/v1/chat/completions",
8336            json!({
8337                "model": "stub-model",
8338                "messages": [{"role": "user", "content": "hello"}]
8339            }),
8340        )
8341        .await;
8342        assert_eq!(response.status(), AxumStatusCode::OK);
8343        let body = response_json(response).await;
8344        assert_eq!(body["choices"][0]["message"]["content"], "chat still works");
8345    }
8346
8347    #[test]
8348    fn sanitized_chat_request_body_redacts_user_text_and_secret_metadata() {
8349        let request = chat_request(json!({
8350            "messages": [{"role": "user", "content": "private prompt"}],
8351            "metadata": {"api_key": "should-not-survive"},
8352            "stream": true
8353        }));
8354        let body = sanitized_chat_request_body(&request);
8355        assert_eq!(body["model"], "stub-model");
8356        assert_eq!(body["stream"], true);
8357        assert_eq!(body["messages"][0]["role"], "user");
8358        assert_eq!(body["messages"][0]["content"], "[redacted]");
8359        assert_eq!(body["messages"][0]["content_redacted"], true);
8360        assert_eq!(body["messages"][0]["content_chars"], 14);
8361        assert_eq!(body["metadata"]["api_key"], "[redacted]");
8362    }
8363
8364    #[test]
8365    fn admission_health_prefers_runtime_authority_over_preflight_estimate() {
8366        let engine_status = EngineStatus {
8367            is_ready: true,
8368            loaded_models: Vec::new(),
8369            active_requests: 2,
8370            queued_requests: 1,
8371            memory_usage: MemoryUsage {
8372                total_bytes: 0,
8373                used_bytes: 0,
8374                free_bytes: 0,
8375                gpu_memory_bytes: None,
8376                cpu_memory_bytes: None,
8377                cache_memory_bytes: 0,
8378                utilization_percent: 0.0,
8379            },
8380            uptime_seconds: 0,
8381            last_heartbeat: chrono::Utc::now(),
8382            version: "test".to_owned(),
8383        };
8384        let runtime = ferrum_types::ExecutorAdmissionSnapshot::new(
8385            ferrum_types::ExecutionResourceAuthority::PlanRuntime,
8386            ferrum_types::ExecutorAdmissionLimits::new(32, 4096).unwrap(),
8387            2,
8388            7,
8389            23,
8390            None,
8391            Some(3),
8392        )
8393        .unwrap();
8394        let admission = admission_health_json(
8395            &engine_status,
8396            &EngineMetrics::default(),
8397            &json!({
8398                "admission": {
8399                    "effective_max_concurrent": 16,
8400                    "scheduler_policy": "continuous"
8401                }
8402            }),
8403            Some(&runtime),
8404            None,
8405        );
8406
8407        assert_eq!(admission["source"], "runtime_executor");
8408        assert_eq!(admission["runtime_snapshot_available"], true);
8409        assert_eq!(admission["resource_authority"], "plan_runtime");
8410        assert_eq!(admission["effective_max_concurrent"], 32);
8411        assert_eq!(admission["maximum_active_sequences"], 32);
8412        assert_eq!(admission["maximum_scheduled_tokens"], 4096);
8413        assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8414        assert_eq!(admission["active_sequences"], 30);
8415        assert_eq!(admission["active_prefill"], 7);
8416        assert_eq!(admission["active_decode"], 23);
8417        assert!(admission["current_batch_size"].is_null());
8418        assert_eq!(admission["queue_depth"], 2);
8419        assert_eq!(admission["capacity_blocked_requests"], 3);
8420    }
8421
8422    #[test]
8423    fn admission_health_surfaces_runtime_contract_failure_without_preflight_fallback() {
8424        let engine_status = EngineStatus {
8425            is_ready: true,
8426            loaded_models: Vec::new(),
8427            active_requests: 32,
8428            queued_requests: 1,
8429            memory_usage: MemoryUsage {
8430                total_bytes: 0,
8431                used_bytes: 0,
8432                free_bytes: 0,
8433                gpu_memory_bytes: None,
8434                cpu_memory_bytes: None,
8435                cache_memory_bytes: 0,
8436                utilization_percent: 0.0,
8437            },
8438            uptime_seconds: 0,
8439            last_heartbeat: chrono::Utc::now(),
8440            version: "test".to_owned(),
8441        };
8442        let admission = admission_health_json(
8443            &engine_status,
8444            &EngineMetrics::default(),
8445            &json!({
8446                "admission": {
8447                    "effective_max_concurrent": 16,
8448                    "scheduler_policy": "continuous"
8449                }
8450            }),
8451            None,
8452            Some("active phase count exceeded the runtime ceiling"),
8453        );
8454
8455        assert_eq!(admission["source"], "runtime_error");
8456        assert_eq!(admission["runtime_snapshot_available"], false);
8457        assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8458        assert!(admission["effective_max_concurrent"].is_null());
8459        assert!(admission["queue_depth"].is_null());
8460        assert_eq!(
8461            admission["runtime_contract_error"],
8462            "active phase count exceeded the runtime ceiling"
8463        );
8464    }
8465
8466    #[tokio::test]
8467    async fn route_health_includes_runtime_config_snapshot() {
8468        let response = get(router_with_stub("ok"), "/health").await;
8469        assert_eq!(response.status(), AxumStatusCode::OK);
8470        let body = response_json(response).await;
8471        assert_eq!(body["status"], "healthy");
8472        assert!(body["config"]["entries"].is_array(), "body: {body}");
8473        assert_eq!(body["auto_config"]["schema_version"], 1);
8474        assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
8475        assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
8476        assert_eq!(body["admission"]["schema_version"], 2);
8477        assert!(body["admission"]["effective_max_concurrent"].is_number());
8478        assert!(body["admission"]["queue_depth"].is_number());
8479        assert!(body["admission"]["active_sequences"].is_number());
8480        assert!(body["admission"]["active_prefill"].is_null());
8481        assert!(body["admission"]["active_decode"].is_null());
8482        assert!(body["admission"]["current_batch_size"].is_null());
8483        assert!(body["admission"]["rejected_requests_total"].is_number());
8484        assert!(body["admission"]["failed_requests_total"].is_number());
8485        assert!(body["admission"]["completed_requests_total"].is_number());
8486        assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
8487        assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
8488        assert!(body["scheduler"]["scheduling_time_ms"].is_number());
8489        assert!(body["scheduler"]["model_execution_time_ms"].is_number());
8490        assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
8491        assert!(
8492            body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
8493            "body: {body}"
8494        );
8495    }
8496
8497    #[tokio::test]
8498    async fn route_metrics_includes_admission_counters() {
8499        let response = get(router_with_stub("ok"), "/metrics").await;
8500        assert_eq!(response.status(), AxumStatusCode::OK);
8501        let body = response_text(response).await;
8502        for metric in [
8503            "ferrum_admission_runtime_snapshot_available",
8504            "ferrum_admission_effective_max_concurrent",
8505            "ferrum_admission_queue_depth",
8506            "ferrum_admission_active_sequences",
8507            "ferrum_admission_rejected_requests_total",
8508            "ferrum_admission_failed_requests_total",
8509            "ferrum_admission_completed_requests_total",
8510        ] {
8511            assert!(body.contains(metric), "missing {metric}:\n{body}");
8512        }
8513        for unavailable_metric in [
8514            "ferrum_admission_maximum_active_sequences ",
8515            "ferrum_admission_maximum_scheduled_tokens ",
8516            "ferrum_admission_capacity_blocked_requests ",
8517            "ferrum_admission_active_prefill ",
8518            "ferrum_admission_active_decode ",
8519            "ferrum_admission_current_batch_size ",
8520        ] {
8521            assert!(
8522                !body.contains(unavailable_metric),
8523                "unknown metric was encoded as a real value: {unavailable_metric}\n{body}"
8524            );
8525        }
8526    }
8527
8528    #[tokio::test]
8529    async fn route_health_includes_engine_lora_metrics_snapshot() {
8530        let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
8531            "ok",
8532            json!({
8533                "enabled": true,
8534                "adapter_count": 1,
8535                "active_cache_bindings": 0,
8536                "projection_applications": 7,
8537                "position": "real-inference",
8538                "source": "test-lora",
8539            }),
8540        )))
8541        .with_lora_adapters(
8542            "stub-model",
8543            vec![LoraAdapterModel::new(
8544                "sql",
8545                "stub-model:sql",
8546                "/tmp/sql-adapter",
8547            )],
8548        )
8549        .unwrap()
8550        .build_router();
8551        let response = get(router, "/health").await;
8552        assert_eq!(response.status(), AxumStatusCode::OK);
8553        let body = response_json(response).await;
8554        assert_eq!(body["lora"]["enabled"], true);
8555        assert_eq!(body["lora"]["adapter_count"], 1);
8556        assert_eq!(body["lora"]["projection_applications"], 7);
8557        assert_eq!(body["lora"]["position"], "real-inference");
8558        assert_eq!(body["lora"]["source"], "test-lora");
8559    }
8560
8561    #[tokio::test]
8562    async fn route_health_includes_engine_execution_attribution_snapshot() {
8563        let router = AxumServer::from_llm(Arc::new(StubLlm::with_execution_attribution(
8564            "ok",
8565            json!({
8566                "schema": "ferrum.vnext.provider-attribution.v1",
8567                "attribution_basis": "resolved_plan_and_completed_static_initialization",
8568                "provider_attribution": {
8569                    "expected_quant_tensor_count": 400,
8570                    "attributed_quant_tensor_count": 400,
8571                    "expected_operation_count": 3,
8572                    "attributed_operation_count": 3,
8573                    "expected_item_count": 403,
8574                    "attributed_item_count": 403,
8575                    "percent": 100.0,
8576                    "denominator_sha256": "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8577                },
8578                "fallback_counts": {"silent": 0, "dense": 0, "legacy": 0}
8579            }),
8580        )))
8581        .build_router();
8582        let response = get(router, "/health").await;
8583        assert_eq!(response.status(), AxumStatusCode::OK);
8584        let body = response_json(response).await;
8585        assert_eq!(
8586            body["execution_attribution"]["provider_attribution"]["expected_item_count"],
8587            403
8588        );
8589        assert_eq!(
8590            body["execution_attribution"]["provider_attribution"]["denominator_sha256"],
8591            "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8592        );
8593        assert_eq!(
8594            body["execution_attribution"]["fallback_counts"],
8595            json!({"silent": 0, "dense": 0, "legacy": 0})
8596        );
8597    }
8598
8599    #[tokio::test]
8600    async fn route_models_lists_loaded_stub_model() {
8601        let response = get(router_with_stub("ok"), "/v1/models").await;
8602        assert_eq!(response.status(), AxumStatusCode::OK);
8603        let body = response_json(response).await;
8604        assert_eq!(body["object"], "list");
8605        let data = body["data"].as_array().expect("models data array");
8606        assert_eq!(data.len(), 1, "body: {body}");
8607        assert_eq!(data[0]["id"], "stub-model");
8608        assert_eq!(data[0]["object"], "model");
8609        assert_eq!(data[0]["owned_by"], "ferrum");
8610        assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
8611        assert_eq!(data[0]["modalities"], json!(["text"]));
8612        assert!(data[0]["permission"].as_array().unwrap().is_empty());
8613        assert!(data[0]["root"].is_null());
8614        assert!(data[0]["parent"].is_null());
8615        assert!(data[0].get("max_model_len").is_none());
8616    }
8617
8618    #[tokio::test]
8619    async fn route_chat_public_alias_maps_to_internal_model_and_is_echoed() {
8620        let engine = Arc::new(CapturingLlm::new());
8621        let registry = ServedModelRegistry::try_new(
8622            "qwen3",
8623            ServedModelKind::Llm,
8624            vec!["served-alias".to_string(), "secondary-alias".to_string()],
8625            vec![],
8626        )
8627        .unwrap();
8628        let router = AxumServer::from_llm(engine.clone())
8629            .with_served_model_registry(registry)
8630            .build_router();
8631        let response = post_json(
8632            router,
8633            "/v1/chat/completions",
8634            json!({
8635                "model": "secondary-alias",
8636                "messages": [{"role": "user", "content": "Say hi"}],
8637                "max_tokens": 8
8638            }),
8639        )
8640        .await;
8641
8642        assert_eq!(response.status(), AxumStatusCode::OK);
8643        let body = response_json(response).await;
8644        assert_eq!(body["model"], "secondary-alias");
8645        assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
8646    }
8647
8648    #[tokio::test]
8649    async fn route_models_lists_public_aliases_without_internal_model_id() {
8650        let registry = ServedModelRegistry::try_new(
8651            "qwen3",
8652            ServedModelKind::Llm,
8653            vec!["served-alias".to_string(), "secondary-alias".to_string()],
8654            vec![],
8655        )
8656        .unwrap();
8657        let router = AxumServer::from_llm(Arc::new(CapturingLlm::new()))
8658            .with_served_model_registry(registry)
8659            .build_router();
8660        let body = response_json(get(router, "/v1/models").await).await;
8661        let ids = body["data"]
8662            .as_array()
8663            .unwrap()
8664            .iter()
8665            .map(|entry| entry["id"].as_str().unwrap())
8666            .collect::<Vec<_>>();
8667
8668        assert_eq!(ids, vec!["served-alias", "secondary-alias"]);
8669        assert!(!ids.contains(&"qwen3"));
8670        assert!(body["data"]
8671            .as_array()
8672            .unwrap()
8673            .iter()
8674            .all(|entry| entry["modalities"] == json!(["text"])));
8675    }
8676
8677    #[tokio::test]
8678    async fn route_models_lists_embedding_registry_capabilities() {
8679        let body = response_json(get(router_with_stub_embed(), "/v1/models").await).await;
8680        let data = body["data"].as_array().unwrap();
8681
8682        assert_eq!(data.len(), 1);
8683        assert_eq!(data[0]["id"], "stub-embed");
8684        assert_eq!(data[0]["modalities"], json!(["text", "image"]));
8685        assert!(data[0].get("max_model_len").is_none());
8686    }
8687
8688    #[tokio::test]
8689    async fn route_models_reports_engine_capacity_for_public_aliases_and_adapters() {
8690        let capacity = 3072;
8691        let engine = StubLlm {
8692            context_capacity: Some(capacity),
8693            ..StubLlm::new("ok")
8694        };
8695        let registry = ServedModelRegistry::try_new(
8696            "stub-model",
8697            ServedModelKind::Llm,
8698            vec!["public-model".to_owned(), "second-alias".to_owned()],
8699            vec![LoraAdapterModel::new(
8700                "sql",
8701                "public-model:sql",
8702                "/tmp/adapter",
8703            )],
8704        )
8705        .unwrap();
8706        let router = AxumServer::from_llm(Arc::new(engine))
8707            .with_served_model_registry(registry)
8708            .build_router();
8709        let body = response_json(get(router, "/v1/models").await).await;
8710        let entries = body["data"].as_array().unwrap();
8711        assert_eq!(entries.len(), 3);
8712        for entry in entries {
8713            assert_eq!(entry["max_model_len"], capacity);
8714        }
8715    }
8716
8717    #[tokio::test]
8718    async fn route_models_lists_startup_lora_adapters() {
8719        let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
8720            .with_lora_adapters(
8721                "stub-model",
8722                vec![LoraAdapterModel::new(
8723                    "sql",
8724                    "stub-model:sql",
8725                    "/tmp/sql-adapter",
8726                )],
8727            )
8728            .unwrap()
8729            .build_router();
8730        let response = get(router, "/v1/models").await;
8731        assert_eq!(response.status(), AxumStatusCode::OK);
8732        let body = response_json(response).await;
8733        let data = body["data"].as_array().expect("models data array");
8734        let ids: Vec<_> = data
8735            .iter()
8736            .map(|item| item["id"].as_str().unwrap_or_default())
8737            .collect();
8738        assert!(ids.contains(&"stub-model"), "body: {body}");
8739        assert!(ids.contains(&"stub-model:sql"), "body: {body}");
8740        let adapter = data
8741            .iter()
8742            .find(|item| item["id"] == "stub-model:sql")
8743            .expect("adapter model");
8744        assert_eq!(adapter["root"], "stub-model");
8745        assert_eq!(adapter["parent"], "stub-model");
8746        assert_eq!(adapter["modalities"], json!(["text"]));
8747    }
8748
8749    #[tokio::test]
8750    async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
8751        let (router, engine) = router_with_capturing_lora_llm();
8752        let response = post_json(
8753            router,
8754            "/v1/chat/completions",
8755            json!({
8756                "model": "qwen3:sql",
8757                "messages": [{"role": "user", "content": "Say hi"}],
8758                "max_tokens": 8,
8759                "temperature": 0.0
8760            }),
8761        )
8762        .await;
8763        assert_eq!(response.status(), AxumStatusCode::OK);
8764        let body = response_json(response).await;
8765        assert_eq!(body["model"], "qwen3:sql");
8766        let captured = engine.last_request();
8767        assert_eq!(captured.model_id, ModelId::new("qwen3"));
8768        assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
8769        assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
8770    }
8771
8772    #[tokio::test]
8773    async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
8774        let (router, engine) = router_with_capturing_lora_llm();
8775        let response = post_json(
8776            router,
8777            "/v1/chat/completions",
8778            json!({
8779                "model": "qwen3",
8780                "messages": [{"role": "user", "content": "Say hi"}],
8781                "max_tokens": 8,
8782                "temperature": 0.0
8783            }),
8784        )
8785        .await;
8786        assert_eq!(response.status(), AxumStatusCode::OK);
8787        let captured = engine.last_request();
8788        assert_eq!(captured.model_id, ModelId::new("qwen3"));
8789        assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
8790    }
8791
8792    #[tokio::test]
8793    async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
8794        let (router, _) = router_with_capturing_lora_llm();
8795        let response = post_json(
8796            router,
8797            "/v1/chat/completions",
8798            json!({
8799                "model": "qwen3:missing",
8800                "messages": [{"role": "user", "content": "Say hi"}],
8801                "max_tokens": 8
8802            }),
8803        )
8804        .await;
8805        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8806        let body = response_json(response).await;
8807        assert_eq!(body["error"]["type"], "invalid_request_error");
8808        assert_eq!(body["error"]["param"], "model");
8809        assert!(
8810            body["error"]["message"]
8811                .as_str()
8812                .unwrap_or_default()
8813                .contains("unknown model"),
8814            "body: {body}"
8815        );
8816    }
8817
8818    #[tokio::test]
8819    async fn route_chat_unknown_served_model_returns_openai_model_error() {
8820        let engine = Arc::new(CapturingLlm::new());
8821        let router = AxumServer::from_llm(engine.clone()).build_router();
8822        let response = post_json(
8823            router,
8824            "/v1/chat/completions",
8825            json!({
8826                "model": "not-a-loaded-model",
8827                "messages": [{"role": "user", "content": "Say hi"}],
8828                "max_tokens": 8
8829            }),
8830        )
8831        .await;
8832        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8833        let body = response_json(response).await;
8834        assert_eq!(body["error"]["type"], "invalid_request_error");
8835        assert_eq!(body["error"]["param"], "model");
8836        assert!(
8837            body["error"]["message"]
8838                .as_str()
8839                .unwrap_or_default()
8840                .contains("unknown model"),
8841            "body: {body}"
8842        );
8843        assert!(!engine.has_captured_request());
8844    }
8845
8846    #[tokio::test]
8847    async fn route_models_without_engine_returns_empty_list() {
8848        let response = get(router_without_llm(), "/v1/models").await;
8849        assert_eq!(response.status(), AxumStatusCode::OK);
8850        let body = response_json(response).await;
8851        assert_eq!(body["object"], "list");
8852        assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
8853    }
8854
8855    #[tokio::test]
8856    async fn route_basic_chat_contract_uses_stub_engine() {
8857        let response = post_json(
8858            router_with_stub("hello"),
8859            "/v1/chat/completions",
8860            json!({
8861                "model": "stub-model",
8862                "messages": [{"role": "user", "content": "Say hi"}],
8863                "max_tokens": 8,
8864                "temperature": 0.0
8865            }),
8866        )
8867        .await;
8868        assert_eq!(response.status(), AxumStatusCode::OK);
8869        let body = response_json(response).await;
8870        assert_eq!(body["object"], "chat.completion");
8871        assert_eq!(body["choices"][0]["message"]["role"], "assistant");
8872        assert_eq!(body["choices"][0]["message"]["content"], "hello");
8873        assert_eq!(body["usage"]["prompt_tokens"], 7);
8874        assert_eq!(body["usage"]["completion_tokens"], 2);
8875    }
8876
8877    #[tokio::test]
8878    async fn route_chat_serializes_structured_tool_call_response() {
8879        let response = post_json(
8880            router_with_stub_api_response(
8881                "",
8882                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8883                    message: ferrum_types::ApiChatMessage {
8884                        role: ferrum_types::ApiMessageRole::Assistant,
8885                        content: String::new(),
8886                        name: None,
8887                        tool_calls: vec![ferrum_types::ApiToolCall {
8888                            id: "call_1".to_string(),
8889                            tool_type: "function".to_string(),
8890                            function: ferrum_types::ApiFunctionCall {
8891                                name: "weather".to_string(),
8892                                arguments: "{\"city\":\"Paris\"}".to_string(),
8893                            },
8894                        }],
8895                        tool_call_id: None,
8896                        function_call: None,
8897                    },
8898                    finish_reason: Some("tool_calls".to_string()),
8899                }),
8900            ),
8901            "/v1/chat/completions",
8902            json!({
8903                "model": "stub-model",
8904                "messages": [{"role": "user", "content": "Use the weather tool."}],
8905                "tools": [{
8906                    "type": "function",
8907                    "function": {
8908                        "name": "weather",
8909                        "parameters": {
8910                            "type": "object",
8911                            "properties": {"city": {"type": "string"}},
8912                            "required": ["city"]
8913                        }
8914                    }
8915                }],
8916                "tool_choice": "auto"
8917            }),
8918        )
8919        .await;
8920        assert_eq!(response.status(), AxumStatusCode::OK);
8921        let body = response_json(response).await;
8922        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8923        assert_eq!(
8924            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8925            "weather"
8926        );
8927        assert_eq!(
8928            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8929            "{\"city\":\"Paris\"}"
8930        );
8931    }
8932
8933    #[tokio::test]
8934    async fn route_chat_preserves_length_over_structured_tool_response() {
8935        let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
8936        let response = post_json(
8937            router_with_stub_api_response_and_finish_reason(
8938                generated,
8939                weather_tool_api_response(),
8940                FinishReason::Length,
8941            ),
8942            "/v1/chat/completions",
8943            json!({
8944                "model": "stub-model",
8945                "messages": [{"role": "user", "content": "Use the weather tool."}],
8946                "tools": [{
8947                    "type": "function",
8948                    "function": {"name": "weather", "parameters": {"type": "object"}}
8949                }],
8950                "tool_choice": "auto"
8951            }),
8952        )
8953        .await;
8954        assert_eq!(response.status(), AxumStatusCode::OK);
8955        let body = response_json(response).await;
8956        assert_eq!(body["choices"][0]["finish_reason"], "length");
8957        assert_eq!(body["choices"][0]["message"]["content"], generated);
8958        assert!(body["choices"][0]["message"]["tool_calls"].is_null());
8959    }
8960
8961    #[tokio::test]
8962    async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
8963        let response = post_json(
8964            router_with_stub(
8965                r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
8966            ),
8967            "/v1/chat/completions",
8968            json!({
8969                "model": "stub-model",
8970                "messages": [{"role": "user", "content": "Use the weather tool."}],
8971                "tools": [{
8972                    "type": "function",
8973                    "function": {
8974                        "name": "weather",
8975                        "parameters": {
8976                            "type": "object",
8977                            "properties": {"city": {"type": "string"}},
8978                            "required": ["city"]
8979                        }
8980                    }
8981                }],
8982                "tool_choice": "auto"
8983            }),
8984        )
8985        .await;
8986        assert_eq!(response.status(), AxumStatusCode::OK);
8987        let body = response_json(response).await;
8988        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8989        assert_eq!(body["choices"][0]["message"]["content"], "");
8990        assert_eq!(
8991            body["choices"][0]["message"]["tool_calls"][0]["id"],
8992            "call_1"
8993        );
8994        assert_eq!(
8995            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8996            "weather"
8997        );
8998        assert_eq!(
8999            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9000            "{\"city\":\"Paris\"}"
9001        );
9002    }
9003
9004    #[tokio::test]
9005    async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
9006        let response = post_json(
9007            router_with_stub(
9008                r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
9009            ),
9010            "/v1/chat/completions",
9011            json!({
9012                "model": "stub-model",
9013                "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
9014                "tools": [{
9015                    "type": "function",
9016                    "function": {
9017                        "name": "get_weather",
9018                        "parameters": {
9019                            "type": "object",
9020                            "properties": {
9021                                "city": {"type": "string"},
9022                                "unit": {"type": "string", "enum": ["c", "f"]}
9023                            },
9024                            "required": ["city"]
9025                        }
9026                    }
9027                }],
9028                "tool_choice": "auto"
9029            }),
9030        )
9031        .await;
9032        assert_eq!(response.status(), AxumStatusCode::OK);
9033        let body = response_json(response).await;
9034        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9035        assert_eq!(body["choices"][0]["message"]["content"], "");
9036        assert_eq!(
9037            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9038            "get_weather"
9039        );
9040        assert_eq!(
9041            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9042            "{\"city\":\"北京\",\"unit\":\"c\"}"
9043        );
9044    }
9045
9046    #[tokio::test]
9047    async fn route_chat_uses_template_tool_protocol_for_function_parameter_xml() {
9048        let template = ModelChatTemplate::new(
9049            "{% 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 %}",
9050            "function-parameter-xml-template",
9051        );
9052        let response = post_json(
9053            router_with_stub_and_template(
9054                "<tool_call>\n<function=get_weather>\n<parameter=city>\n北京\n</parameter>\n<parameter=unit>\ncelsius\n</parameter>\n</function>\n</tool_call>",
9055                template,
9056            ),
9057            "/v1/chat/completions",
9058            json!({
9059                "model": "stub-model",
9060                "messages": [{"role": "user", "content": "请调用 get_weather 查询北京天气。"}],
9061                "tools": [{
9062                    "type": "function",
9063                    "function": {
9064                        "name": "get_weather",
9065                        "parameters": {
9066                            "type": "object",
9067                            "properties": {
9068                                "city": {"type": "string"},
9069                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9070                            },
9071                            "required": ["city"]
9072                        }
9073                    }
9074                }]
9075            }),
9076        )
9077        .await;
9078
9079        assert_eq!(response.status(), AxumStatusCode::OK);
9080        let body = response_json(response).await;
9081        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9082        assert_eq!(
9083            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9084            "get_weather"
9085        );
9086        assert_eq!(
9087            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9088            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9089        );
9090    }
9091
9092    fn xml_object_argument_tool_request(stream: bool) -> Value {
9093        json!({
9094            "model": "stub-model",
9095            "messages": [{"role": "user", "content": "Weather in Berlin with a forecast."}],
9096            "stream": stream,
9097            "tools": [{
9098                "type": "function",
9099                "function": {
9100                    "name": "get_weather",
9101                    "parameters": {
9102                        "type": "object",
9103                        "$defs": {
9104                            "WeatherOptions": {
9105                                "type": "object",
9106                                "properties": {
9107                                    "unit": {"type": "string"},
9108                                    "include_forecast": {"type": "boolean"}
9109                                },
9110                                "required": ["unit", "include_forecast"],
9111                                "additionalProperties": false
9112                            }
9113                        },
9114                        "properties": {
9115                            "city": {"type": "string"},
9116                            "options": {"$ref": "#/$defs/WeatherOptions"}
9117                        },
9118                        "required": ["city", "options"],
9119                        "additionalProperties": false
9120                    }
9121                }
9122            }]
9123        })
9124    }
9125
9126    #[tokio::test]
9127    async fn route_chat_decodes_xml_object_argument_through_local_schema_ref() {
9128        let template = ModelChatTemplate::new(
9129            "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9130            "function-parameter-xml-template",
9131        );
9132        let response = post_json(
9133            router_with_stub_and_template(
9134                concat!(
9135                    "<tool_call>\n",
9136                    "<function=get_weather>\n",
9137                    "<parameter=city>\nBerlin\n</parameter>\n",
9138                    "<parameter=options>\n",
9139                    "{\"unit\":\"celsius\",\"include_forecast\":true}\n",
9140                    "</parameter>\n",
9141                    "</function>\n",
9142                    "</tool_call>",
9143                ),
9144                template,
9145            ),
9146            "/v1/chat/completions",
9147            xml_object_argument_tool_request(false),
9148        )
9149        .await;
9150
9151        assert_eq!(response.status(), AxumStatusCode::OK);
9152        let body = response_json(response).await;
9153        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9154        let arguments = body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
9155            .as_str()
9156            .and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
9157            .expect("tool arguments must contain one-decode structured JSON");
9158        assert_eq!(arguments["city"], json!("Berlin"));
9159        assert_eq!(
9160            arguments["options"],
9161            json!({"unit": "celsius", "include_forecast": true})
9162        );
9163    }
9164
9165    #[tokio::test]
9166    async fn route_chat_rejects_malformed_native_xml_object_argument() {
9167        let template = ModelChatTemplate::new(
9168            "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9169            "function-parameter-xml-template",
9170        );
9171        let mut request = xml_object_argument_tool_request(false);
9172        request["tools"][0]["function"]["strict"] = json!(true);
9173        let response = post_json(
9174            router_with_stub_and_template(
9175                concat!(
9176                    "<tool_call><function=get_weather>",
9177                    "<parameter=city>Berlin</parameter>",
9178                    "<parameter=options>{\"unit\":\"celsius\",</parameter>",
9179                    "</function></tool_call>",
9180                ),
9181                template,
9182            ),
9183            "/v1/chat/completions",
9184            request,
9185        )
9186        .await;
9187
9188        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9189        let body = response_json(response).await;
9190        assert_eq!(body["error"]["type"], "internal_server_error");
9191        assert!(
9192            body["error"]["message"]
9193                .as_str()
9194                .is_some_and(|message| message.contains("did not satisfy its schema")),
9195            "body: {body}"
9196        );
9197    }
9198
9199    #[tokio::test]
9200    async fn route_streaming_chat_rejects_malformed_native_xml_before_tool_delta() {
9201        let template = ModelChatTemplate::new(
9202            "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9203            "function-parameter-xml-template",
9204        );
9205        let mut request = xml_object_argument_tool_request(true);
9206        request["tools"][0]["function"]["strict"] = json!(true);
9207        let response = post_json(
9208            router_with_stub_and_template(
9209                concat!(
9210                    "<tool_call><function=get_weather>",
9211                    "<parameter=city>Berlin</parameter>",
9212                    "<parameter=options>{\"unit\":\"celsius\",</parameter>",
9213                    "</function></tool_call>",
9214                ),
9215                template,
9216            ),
9217            "/v1/chat/completions",
9218            request,
9219        )
9220        .await;
9221
9222        assert_eq!(response.status(), AxumStatusCode::OK);
9223        let body = response_text(response).await;
9224        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9225        assert!(
9226            body.contains(r#""error":{"#) && body.contains("did not satisfy its schema"),
9227            "stream must return a controlled schema error: {body}"
9228        );
9229        assert!(
9230            !body.contains(r#""tool_calls":[{"#),
9231            "invalid native arguments must not leak a tool delta: {body}"
9232        );
9233    }
9234
9235    #[tokio::test]
9236    async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
9237        let response = post_json(
9238            router_with_stub(
9239                "kaza\n\
9240                 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
9241                 </think>\n\
9242                 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
9243                 {\"temperature\":25,\"condition\":\"晴\"}",
9244            ),
9245            "/v1/chat/completions",
9246            json!({
9247                "model": "stub-model",
9248                "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
9249                "tools": [{
9250                    "type": "function",
9251                    "function": {
9252                        "name": "get_weather",
9253                        "parameters": {
9254                            "type": "object",
9255                            "properties": {
9256                                "city": {"type": "string"},
9257                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9258                            },
9259                            "required": ["city"]
9260                        }
9261                    }
9262                }]
9263            }),
9264        )
9265        .await;
9266        assert_eq!(response.status(), AxumStatusCode::OK);
9267        let body = response_json(response).await;
9268        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9269        assert_eq!(body["choices"][0]["message"]["content"], "");
9270        assert_eq!(
9271            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9272            "get_weather"
9273        );
9274        assert_eq!(
9275            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9276            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9277        );
9278    }
9279
9280    #[tokio::test]
9281    async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
9282        let response = post_json(
9283            router_with_stub(
9284                "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
9285                 </think>\n\
9286                 {\"name\":\"get_weather\",\"arguments\":{}}",
9287            ),
9288            "/v1/chat/completions",
9289            json!({
9290                "model": "stub-model",
9291                "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
9292                "tools": [{
9293                    "type": "function",
9294                    "function": {
9295                        "name": "get_weather",
9296                        "parameters": {
9297                            "type": "object",
9298                            "properties": {
9299                                "city": {"type": "string"},
9300                                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9301                            },
9302                            "required": ["city"]
9303                        }
9304                    }
9305                }]
9306            }),
9307        )
9308        .await;
9309        assert_eq!(response.status(), AxumStatusCode::OK);
9310        let body = response_json(response).await;
9311        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9312        assert_eq!(
9313            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9314            "get_weather"
9315        );
9316        assert_eq!(
9317            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9318            "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9319        );
9320    }
9321
9322    #[tokio::test]
9323    async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
9324        let response = post_json(
9325            router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
9326            "/v1/chat/completions",
9327            json!({
9328                "model": "stub-model",
9329                "messages": [{"role": "user", "content": "Use the selected tool."}],
9330                "tools": [
9331                    {
9332                        "type": "function",
9333                        "function": {"name": "weather", "parameters": {"type": "object"}}
9334                    },
9335                    {
9336                        "type": "function",
9337                        "function": {"name": "calendar", "parameters": {"type": "object"}}
9338                    }
9339                ],
9340                "tool_choice": {
9341                    "type": "function",
9342                    "function": {"name": "weather"}
9343                }
9344            }),
9345        )
9346        .await;
9347        assert_eq!(response.status(), AxumStatusCode::OK);
9348        let body = response_json(response).await;
9349        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9350        assert_eq!(
9351            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9352            "weather"
9353        );
9354
9355        let response = post_json(
9356            router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
9357            "/v1/chat/completions",
9358            json!({
9359                "model": "stub-model",
9360                "messages": [{"role": "user", "content": "Use the selected tool."}],
9361                "tools": [
9362                    {
9363                        "type": "function",
9364                        "function": {"name": "weather", "parameters": {"type": "object"}}
9365                    },
9366                    {
9367                        "type": "function",
9368                        "function": {"name": "calendar", "parameters": {"type": "object"}}
9369                    }
9370                ],
9371                "tool_choice": {
9372                    "type": "function",
9373                    "function": {"name": "weather"}
9374                }
9375            }),
9376        )
9377        .await;
9378        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9379        let body = response_json(response).await;
9380        assert_eq!(body["error"]["param"], "tool_choice");
9381        assert_eq!(body["error"]["type"], "invalid_request_error");
9382    }
9383
9384    #[tokio::test]
9385    async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
9386        let response = post_json(
9387            router_with_stub(r#"{"city":"Paris"}"#),
9388            "/v1/chat/completions",
9389            json!({
9390                "model": "stub-model",
9391                "messages": [{"role": "user", "content": "Use the selected tool."}],
9392                "tools": [{
9393                    "type": "function",
9394                    "function": {
9395                        "name": "weather",
9396                        "parameters": {
9397                            "type": "object",
9398                            "properties": {"city": {"type": "string"}},
9399                            "required": ["city"]
9400                        }
9401                    }
9402                }],
9403                "tool_choice": {
9404                    "type": "function",
9405                    "function": {"name": "weather"}
9406                }
9407            }),
9408        )
9409        .await;
9410        assert_eq!(response.status(), AxumStatusCode::OK);
9411        let body = response_json(response).await;
9412        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9413        assert_eq!(body["choices"][0]["message"]["content"], "");
9414        assert_eq!(
9415            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9416            "weather"
9417        );
9418        assert_eq!(
9419            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9420            "{\"city\":\"Paris\"}"
9421        );
9422    }
9423
9424    #[tokio::test]
9425    async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
9426        let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9427        let response = post_json(
9428            router_with_stub(generated),
9429            "/v1/chat/completions",
9430            json!({
9431                "model": "stub-model",
9432                "messages": [{"role": "user", "content": "Do not use tools."}],
9433                "tools": [{
9434                    "type": "function",
9435                    "function": {"name": "weather", "parameters": {"type": "object"}}
9436                }],
9437                "tool_choice": "none"
9438            }),
9439        )
9440        .await;
9441        assert_eq!(response.status(), AxumStatusCode::OK);
9442        let body = response_json(response).await;
9443        assert_eq!(body["choices"][0]["finish_reason"], "stop");
9444        assert_eq!(body["choices"][0]["message"]["content"], generated);
9445        assert!(body["choices"][0]["message"]["tool_calls"].is_null());
9446    }
9447
9448    #[tokio::test]
9449    async fn route_chat_tool_choice_required_wraps_generated_arguments() {
9450        let response = post_json(
9451            router_with_stub(r#"{"city":"Paris"}"#),
9452            "/v1/chat/completions",
9453            json!({
9454                "model": "stub-model",
9455                "messages": [{"role": "user", "content": "Use a tool."}],
9456                "tools": [{
9457                    "type": "function",
9458                    "function": {
9459                        "name": "weather",
9460                        "parameters": {
9461                            "type": "object",
9462                            "properties": {"city": {"type": "string"}},
9463                            "required": ["city"]
9464                        }
9465                    }
9466                }],
9467                "tool_choice": "required"
9468            }),
9469        )
9470        .await;
9471        assert_eq!(response.status(), AxumStatusCode::OK);
9472        let body = response_json(response).await;
9473        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9474        assert_eq!(body["choices"][0]["message"]["content"], "");
9475        assert_eq!(
9476            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9477            "weather"
9478        );
9479        assert_eq!(
9480            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9481            "{\"city\":\"Paris\"}"
9482        );
9483    }
9484
9485    fn required_tool_with_strict_response_format_request(stream: bool) -> Value {
9486        json!({
9487            "model": "stub-model",
9488            "messages": [{"role": "user", "content": "Use the weather tool."}],
9489            "stream": stream,
9490            "stream_options": stream.then_some(json!({"include_usage": true})),
9491            "tools": [{
9492                "type": "function",
9493                "function": {
9494                    "name": "weather",
9495                    "parameters": {
9496                        "type": "object",
9497                        "properties": {"city": {"type": "string", "const": "Paris"}},
9498                        "required": ["city"],
9499                        "additionalProperties": false
9500                    }
9501                }
9502            }],
9503            "tool_choice": "required",
9504            "response_format": {
9505                "type": "json_schema",
9506                "json_schema": {
9507                    "name": "content_answer",
9508                    "strict": true,
9509                    "schema": {
9510                        "type": "object",
9511                        "properties": {"answer": {"type": "string", "const": "IGNORED"}},
9512                        "required": ["answer"],
9513                        "additionalProperties": false
9514                    }
9515                }
9516            }
9517        })
9518    }
9519
9520    #[tokio::test]
9521    async fn route_chat_required_tool_takes_priority_over_strict_response_format() {
9522        let response = post_json(
9523            router_with_stub(r#"{"city":"Paris"}"#),
9524            "/v1/chat/completions",
9525            required_tool_with_strict_response_format_request(false),
9526        )
9527        .await;
9528        assert_eq!(response.status(), AxumStatusCode::OK);
9529        let body = response_json(response).await;
9530        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9531        assert_eq!(body["choices"][0]["message"]["content"], "");
9532        assert_eq!(
9533            body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9534            "weather"
9535        );
9536        assert_eq!(
9537            body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9538            r#"{"city":"Paris"}"#
9539        );
9540    }
9541
9542    #[tokio::test]
9543    async fn route_chat_required_tool_rejects_arguments_that_violate_const_schema() {
9544        let response = post_json(
9545            router_with_stub(r#"{"city":"London"}"#),
9546            "/v1/chat/completions",
9547            required_tool_with_strict_response_format_request(false),
9548        )
9549        .await;
9550        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9551        let body = response_json(response).await;
9552        assert_eq!(body["error"]["type"], "internal_server_error");
9553        assert!(
9554            body["error"]["message"]
9555                .as_str()
9556                .is_some_and(|message| message.contains("did not satisfy its schema")),
9557            "body: {body}"
9558        );
9559    }
9560
9561    #[tokio::test]
9562    async fn route_streaming_required_tool_takes_priority_over_strict_response_format() {
9563        let response = post_json(
9564            router_with_stub(r#"{"city":"Paris"}"#),
9565            "/v1/chat/completions",
9566            required_tool_with_strict_response_format_request(true),
9567        )
9568        .await;
9569        assert_eq!(response.status(), AxumStatusCode::OK);
9570        let body = response_text(response).await;
9571        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9572        assert!(
9573            body.contains(r#""finish_reason":"tool_calls""#),
9574            "tool priority must finish with tool_calls: {body}"
9575        );
9576        assert!(
9577            body.contains(r#""name":"weather""#)
9578                && body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9579            "stream must carry the reconstructed tool call: {body}"
9580        );
9581        assert_eq!(
9582            body.matches(r#""usage":{"#).count(),
9583            1,
9584            "stream must carry exactly one usage row: {body}"
9585        );
9586        assert!(
9587            !body.contains("strict json_schema") && !body.contains("invalid JSON"),
9588            "dormant content schema must not reject a required tool call: {body}"
9589        );
9590    }
9591
9592    #[tokio::test]
9593    async fn dropping_buffered_http_response_drops_the_engine_stream() {
9594        let stream_dropped = Arc::new(Notify::new());
9595        let response = post_json(
9596            AxumServer::from_llm(Arc::new(StubLlm::with_pending_stream(Arc::clone(
9597                &stream_dropped,
9598            ))))
9599            .build_router(),
9600            "/v1/chat/completions",
9601            required_tool_with_strict_response_format_request(true),
9602        )
9603        .await;
9604        assert_eq!(response.status(), AxumStatusCode::OK);
9605
9606        drop(response);
9607        tokio::time::timeout(std::time::Duration::from_secs(1), stream_dropped.notified())
9608            .await
9609            .expect("client disconnect must stop a buffered structured stream promptly");
9610    }
9611
9612    #[tokio::test]
9613    async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
9614        let response = post_json(
9615            router_with_stub("plain answer"),
9616            "/v1/chat/completions",
9617            json!({
9618                "model": "stub-model",
9619                "messages": [{"role": "user", "content": "Use a tool."}],
9620                "tools": [{
9621                    "type": "function",
9622                    "function": {"name": "weather", "parameters": {"type": "object"}}
9623                }],
9624                "tool_choice": "required"
9625            }),
9626        )
9627        .await;
9628        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9629        let body = response_json(response).await;
9630        assert_eq!(body["error"]["type"], "invalid_request_error");
9631        assert_eq!(body["error"]["param"], "tool_choice");
9632        assert!(
9633            body["error"]["message"]
9634                .as_str()
9635                .is_some_and(|message| message.contains("required tool_choice")),
9636            "body: {body}"
9637        );
9638    }
9639
9640    #[tokio::test]
9641    async fn route_streaming_chat_serializes_generated_tool_call_delta() {
9642        let response = post_json(
9643            router_with_stub(
9644                r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
9645            ),
9646            "/v1/chat/completions",
9647            json!({
9648                "model": "stub-model",
9649                "messages": [{"role": "user", "content": "Use the weather tool."}],
9650                "stream": true,
9651                "tools": [{
9652                    "type": "function",
9653                    "function": {
9654                        "name": "weather",
9655                        "parameters": {
9656                            "type": "object",
9657                            "properties": {"city": {"type": "string"}},
9658                            "required": ["city"]
9659                        }
9660                    }
9661                }],
9662                "tool_choice": "auto"
9663            }),
9664        )
9665        .await;
9666        assert_eq!(response.status(), AxumStatusCode::OK);
9667        let body = response_text(response).await;
9668        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9669        assert!(
9670            body.contains(r#""finish_reason":"tool_calls""#),
9671            "stream should finish with tool_calls: {body}"
9672        );
9673        assert!(
9674            body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
9675            "stream should emit OpenAI tool_calls delta with index: {body}"
9676        );
9677        assert!(
9678            body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9679            "tool arguments should be serialized as JSON string: {body}"
9680        );
9681        assert!(
9682            !body.contains(r#""content":"{\"tool_calls\""#),
9683            "raw tool-call JSON should not be streamed as assistant content: {body}"
9684        );
9685    }
9686
9687    #[tokio::test]
9688    async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
9689        let response = post_json(
9690            router_with_stub(
9691                r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
9692            ),
9693            "/v1/chat/completions",
9694            json!({
9695                "model": "stub-model",
9696                "messages": [{"role": "user", "content": "深圳天气?"}],
9697                "stream": true,
9698                "tools": [{
9699                    "type": "function",
9700                    "function": {
9701                        "name": "get_weather",
9702                        "parameters": {
9703                            "type": "object",
9704                            "properties": {
9705                                "city": {"type": "string"},
9706                                "unit": {"type": "string", "enum": ["c", "f"]}
9707                            },
9708                            "required": ["city"]
9709                        }
9710                    }
9711                }],
9712                "tool_choice": "auto"
9713            }),
9714        )
9715        .await;
9716        assert_eq!(response.status(), AxumStatusCode::OK);
9717        let body = response_text(response).await;
9718        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9719        assert!(
9720            body.contains(r#""finish_reason":"tool_calls""#),
9721            "stream should finish with tool_calls: {body}"
9722        );
9723        assert!(
9724            body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
9725            "stream should emit parsed Qwen3 function parameters as tool args: {body}"
9726        );
9727        assert!(
9728            !body.contains(r#""content":"{\"function\""#),
9729            "raw Qwen3 tool JSON should not leak as assistant content: {body}"
9730        );
9731    }
9732
9733    #[tokio::test]
9734    async fn route_streaming_chat_preserves_opencode_edit_xml_whitespace() {
9735        let template = ModelChatTemplate::new(
9736            "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9737            "function-parameter-xml-template",
9738        );
9739        let generated = concat!(
9740            "<tool_call>\n",
9741            "<function=edit>\n",
9742            "<parameter=filePath>\n",
9743            "/workspace/src/main.rs\n",
9744            "</parameter>\n",
9745            "<parameter=oldString>\n",
9746            "    if x:\n",
9747            "        return 1\n",
9748            "\n",
9749            "</parameter>\n",
9750            "<parameter=newString>\n",
9751            "    if x:\n",
9752            "        return 2\n",
9753            "\n",
9754            "</parameter>\n",
9755            "<parameter=replaceAll>\n",
9756            "true\n",
9757            "</parameter>\n",
9758            "</function>\n",
9759            "</tool_call>",
9760        );
9761        let response = post_json(
9762            router_with_stub_and_template(generated, template),
9763            "/v1/chat/completions",
9764            json!({
9765                "model": "stub-model",
9766                "messages": [{"role": "user", "content": "Replace the code."}],
9767                "stream": true,
9768                "tools": [{
9769                    "type": "function",
9770                    "function": {
9771                        "name": "edit",
9772                        "parameters": {
9773                            "type": "object",
9774                            "properties": {
9775                                "filePath": {"type": "string"},
9776                                "oldString": {"type": "string"},
9777                                "newString": {"type": "string"},
9778                                "replaceAll": {"type": "boolean"}
9779                            },
9780                            "required": ["filePath", "oldString", "newString"]
9781                        }
9782                    }
9783                }]
9784            }),
9785        )
9786        .await;
9787
9788        assert_eq!(response.status(), AxumStatusCode::OK);
9789        let body = response_text(response).await;
9790        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9791        assert!(
9792            body.contains(r#"\"oldString\":\"    if x:\\n        return 1\\n\""#),
9793            "stream must preserve exact code whitespace in tool arguments: {body}"
9794        );
9795        assert!(
9796            body.contains(r#"\"replaceAll\":true"#),
9797            "stream must preserve the boolean tool argument type: {body}"
9798        );
9799    }
9800
9801    #[tokio::test]
9802    async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
9803        let request = |generated: &'static str| {
9804            post_json(
9805                router_with_stub(generated),
9806                "/v1/chat/completions",
9807                json!({
9808                    "model": "stub-model",
9809                    "messages": [{"role": "user", "content": "Use the selected tool."}],
9810                    "stream": true,
9811                    "tools": [
9812                        {
9813                            "type": "function",
9814                            "function": {"name": "weather", "parameters": {"type": "object"}}
9815                        },
9816                        {
9817                            "type": "function",
9818                            "function": {"name": "calendar", "parameters": {"type": "object"}}
9819                        }
9820                    ],
9821                    "tool_choice": {
9822                        "type": "function",
9823                        "function": {"name": "weather"}
9824                    }
9825                }),
9826            )
9827        };
9828
9829        let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
9830        assert_eq!(response.status(), AxumStatusCode::OK);
9831        let body = response_text(response).await;
9832        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9833        assert!(
9834            body.contains(r#""finish_reason":"tool_calls""#),
9835            "selected tool should finish with tool_calls: {body}"
9836        );
9837        assert!(
9838            body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
9839            "selected tool should stream as tool_calls delta: {body}"
9840        );
9841
9842        let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
9843        assert_eq!(response.status(), AxumStatusCode::OK);
9844        let body = response_text(response).await;
9845        assert!(
9846            body.contains(
9847                r#""error":{"message":"model output did not satisfy required tool_choice""#
9848            ),
9849            "selected-tool stream should reject unselected tool output: {body}"
9850        );
9851        assert!(
9852            !body.contains(r#""finish_reason":"tool_calls""#),
9853            "unselected tool JSON must not become tool_calls: {body}"
9854        );
9855    }
9856
9857    #[tokio::test]
9858    async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
9859        let response = post_json(
9860            router_with_stub_api_response(
9861                "raw text that should not stream",
9862                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
9863                    message: ferrum_types::ApiChatMessage {
9864                        role: ferrum_types::ApiMessageRole::Assistant,
9865                        content: String::new(),
9866                        name: None,
9867                        tool_calls: vec![ferrum_types::ApiToolCall {
9868                            id: "call_1".to_string(),
9869                            tool_type: "function".to_string(),
9870                            function: ferrum_types::ApiFunctionCall {
9871                                name: "weather".to_string(),
9872                                arguments: "{\"city\":\"Paris\"}".to_string(),
9873                            },
9874                        }],
9875                        tool_call_id: None,
9876                        function_call: None,
9877                    },
9878                    finish_reason: Some("tool_calls".to_string()),
9879                }),
9880            ),
9881            "/v1/chat/completions",
9882            json!({
9883                "model": "stub-model",
9884                "messages": [{"role": "user", "content": "Use the weather tool."}],
9885                "stream": true,
9886                "tools": [{
9887                    "type": "function",
9888                    "function": {"name": "weather", "parameters": {"type": "object"}}
9889                }],
9890                "tool_choice": "auto"
9891            }),
9892        )
9893        .await;
9894        assert_eq!(response.status(), AxumStatusCode::OK);
9895        let body = response_text(response).await;
9896        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9897        assert!(
9898            body.contains(r#""finish_reason":"tool_calls""#),
9899            "stream should finish with tool_calls: {body}"
9900        );
9901        assert!(
9902            body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
9903            "stream should emit tool_calls from chunk api_response: {body}"
9904        );
9905        assert!(
9906            !body.contains("raw text that should not stream"),
9907            "structured api_response should suppress raw generated text in tool-call stream: {body}"
9908        );
9909    }
9910
9911    #[tokio::test]
9912    async fn route_streaming_chat_preserves_length_over_structured_tool_response() {
9913        let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9914        let response = post_json(
9915            router_with_stub_api_response_and_finish_reason(
9916                generated,
9917                weather_tool_api_response(),
9918                FinishReason::Length,
9919            ),
9920            "/v1/chat/completions",
9921            json!({
9922                "model": "stub-model",
9923                "messages": [{"role": "user", "content": "Use the weather tool."}],
9924                "stream": true,
9925                "tools": [{
9926                    "type": "function",
9927                    "function": {"name": "weather", "parameters": {"type": "object"}}
9928                }],
9929                "tool_choice": "auto"
9930            }),
9931        )
9932        .await;
9933        assert_eq!(response.status(), AxumStatusCode::OK);
9934        let body = response_text(response).await;
9935        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9936        assert!(
9937            body.contains(r#""finish_reason":"length""#),
9938            "stream must preserve the engine terminal reason: {body}"
9939        );
9940        assert!(
9941            !body.contains(r#""finish_reason":"tool_calls""#),
9942            "length must not be relabeled as tool_calls: {body}"
9943        );
9944    }
9945
9946    #[tokio::test]
9947    async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
9948        let response = post_json(
9949            router_with_stub("plain answer"),
9950            "/v1/chat/completions",
9951            json!({
9952                "model": "stub-model",
9953                "messages": [{"role": "user", "content": "Use a tool."}],
9954                "stream": true,
9955                "tools": [{
9956                    "type": "function",
9957                    "function": {"name": "weather", "parameters": {"type": "object"}}
9958                }],
9959                "tool_choice": "required"
9960            }),
9961        )
9962        .await;
9963        assert_eq!(response.status(), AxumStatusCode::OK);
9964        let body = response_text(response).await;
9965        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9966        assert!(
9967            body.contains(
9968                r#""error":{"message":"model output did not satisfy required tool_choice""#
9969            ),
9970            "stream should emit OpenAI error envelope: {body}"
9971        );
9972        assert!(
9973            body.contains(r#""type":"invalid_request_error""#),
9974            "stream should use invalid_request_error: {body}"
9975        );
9976        assert!(
9977            body.contains(r#""param":"tool_choice""#),
9978            "stream should include tool_choice param: {body}"
9979        );
9980        assert!(
9981            !body.contains(r#""content":"plain answer""#),
9982            "required stream must not leak invalid content before validation: {body}"
9983        );
9984    }
9985
9986    #[tokio::test]
9987    async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
9988        let response = post_json(
9989            router_with_stub("plain answer"),
9990            "/v1/chat/completions",
9991            json!({
9992                "model": "stub-model",
9993                "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
9994                "stream": true,
9995                "tools": [{
9996                    "type": "function",
9997                    "function": {"name": "weather", "parameters": {"type": "object"}}
9998                }],
9999                "tool_choice": "auto"
10000            }),
10001        )
10002        .await;
10003        assert_eq!(response.status(), AxumStatusCode::OK);
10004        let body = response_text(response).await;
10005        assert!(
10006            body.contains(r#""content":"plain answer""#),
10007            "plain content should still stream when no tool call is generated: {body}"
10008        );
10009        assert!(
10010            body.contains(r#""finish_reason":"stop""#),
10011            "plain content should keep normal finish reason: {body}"
10012        );
10013        assert!(
10014            !body.contains(r#""tool_calls""#),
10015            "fallback content should not synthesize tool_calls: {body}"
10016        );
10017    }
10018
10019    #[tokio::test]
10020    async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
10021        let response = post_json(
10022            router_with_stub(
10023                r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
10024            ),
10025            "/v1/chat/completions",
10026            json!({
10027                "model": "stub-model",
10028                "messages": [{"role": "user", "content": "Use the weather function."}],
10029                "stream": true,
10030                "functions": [{
10031                    "name": "weather",
10032                    "parameters": {
10033                        "type": "object",
10034                        "properties": {"city": {"type": "string"}},
10035                        "required": ["city"]
10036                    }
10037                }],
10038                "function_call": "auto"
10039            }),
10040        )
10041        .await;
10042        assert_eq!(response.status(), AxumStatusCode::OK);
10043        let body = response_text(response).await;
10044        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10045        assert!(
10046            body.contains(r#""finish_reason":"function_call""#),
10047            "stream should finish with function_call: {body}"
10048        );
10049        assert!(
10050            body.contains(
10051                r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
10052            ),
10053            "stream should emit OpenAI legacy function_call delta: {body}"
10054        );
10055        assert!(
10056            !body.contains(r#""content":"{\"function_call\""#),
10057            "raw function-call JSON should not be streamed as assistant content: {body}"
10058        );
10059    }
10060
10061    #[tokio::test]
10062    async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
10063        let request = |generated: &'static str| {
10064            post_json(
10065                router_with_stub(generated),
10066                "/v1/chat/completions",
10067                json!({
10068                    "model": "stub-model",
10069                    "messages": [{"role": "user", "content": "Use the selected function."}],
10070                    "stream": true,
10071                    "functions": [
10072                        {"name": "weather", "parameters": {"type": "object"}},
10073                        {"name": "calendar", "parameters": {"type": "object"}}
10074                    ],
10075                    "function_call": {"name": "weather"}
10076                }),
10077            )
10078        };
10079
10080        let response =
10081            request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
10082        assert_eq!(response.status(), AxumStatusCode::OK);
10083        let body = response_text(response).await;
10084        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10085        assert!(
10086            body.contains(r#""finish_reason":"function_call""#),
10087            "selected function should finish with function_call: {body}"
10088        );
10089        assert!(
10090            body.contains(
10091                r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
10092            ),
10093            "selected function should stream as function_call delta: {body}"
10094        );
10095
10096        let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
10097        assert_eq!(response.status(), AxumStatusCode::OK);
10098        let body = response_text(response).await;
10099        assert!(
10100            body.contains(
10101                r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
10102            ),
10103            "unselected function JSON should stream as ordinary content: {body}"
10104        );
10105        assert!(
10106            body.contains(r#""finish_reason":"stop""#),
10107            "unselected function JSON should keep normal stop finish: {body}"
10108        );
10109        assert!(
10110            !body.contains(r#""finish_reason":"function_call""#),
10111            "unselected function JSON must not become function_call: {body}"
10112        );
10113    }
10114
10115    #[tokio::test]
10116    async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
10117        let response = post_json(
10118            router_with_stub(
10119                r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
10120            ),
10121            "/v1/chat/completions",
10122            json!({
10123                "model": "stub-model",
10124                "messages": [{"role": "user", "content": "Use the weather function."}],
10125                "functions": [{
10126                    "name": "weather",
10127                    "parameters": {
10128                        "type": "object",
10129                        "properties": {"city": {"type": "string"}},
10130                        "required": ["city"]
10131                    }
10132                }],
10133                "function_call": "auto"
10134            }),
10135        )
10136        .await;
10137        assert_eq!(response.status(), AxumStatusCode::OK);
10138        let body = response_json(response).await;
10139        assert_eq!(body["choices"][0]["finish_reason"], "function_call");
10140        assert_eq!(body["choices"][0]["message"]["content"], "");
10141        assert_eq!(
10142            body["choices"][0]["message"]["function_call"]["name"],
10143            "weather"
10144        );
10145        assert_eq!(
10146            body["choices"][0]["message"]["function_call"]["arguments"],
10147            "{\"city\":\"Paris\"}"
10148        );
10149    }
10150
10151    #[tokio::test]
10152    async fn route_chat_serializes_legacy_function_call_response() {
10153        let response = post_json(
10154            router_with_stub_api_response(
10155                "",
10156                ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
10157                    message: ferrum_types::ApiChatMessage {
10158                        role: ferrum_types::ApiMessageRole::Assistant,
10159                        content: String::new(),
10160                        name: None,
10161                        tool_calls: vec![],
10162                        tool_call_id: None,
10163                        function_call: Some(ferrum_types::ApiFunctionCall {
10164                            name: "weather".to_string(),
10165                            arguments: "{\"city\":\"Paris\"}".to_string(),
10166                        }),
10167                    },
10168                    finish_reason: Some("function_call".to_string()),
10169                }),
10170            ),
10171            "/v1/chat/completions",
10172            json!({
10173                "model": "stub-model",
10174                "messages": [{"role": "user", "content": "Use the weather function."}],
10175                "functions": [{
10176                    "name": "weather",
10177                    "parameters": {
10178                        "type": "object",
10179                        "properties": {"city": {"type": "string"}},
10180                        "required": ["city"]
10181                    }
10182                }],
10183                "function_call": "auto"
10184            }),
10185        )
10186        .await;
10187        assert_eq!(response.status(), AxumStatusCode::OK);
10188        let body = response_json(response).await;
10189        assert_eq!(body["choices"][0]["finish_reason"], "function_call");
10190        assert_eq!(
10191            body["choices"][0]["message"]["function_call"]["name"],
10192            "weather"
10193        );
10194        assert_eq!(
10195            body["choices"][0]["message"]["function_call"]["arguments"],
10196            "{\"city\":\"Paris\"}"
10197        );
10198    }
10199
10200    #[tokio::test]
10201    async fn route_streaming_chat_include_usage_contract() {
10202        let response = post_json(
10203            router_with_stub("ok"),
10204            "/v1/chat/completions",
10205            json!({
10206                "model": "stub-model",
10207                "messages": [{"role": "user", "content": "Say ok"}],
10208                "stream": true,
10209                "stream_options": {"include_usage": true}
10210            }),
10211        )
10212        .await;
10213        assert_eq!(response.status(), AxumStatusCode::OK);
10214        let body = response_text(response).await;
10215        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10216        assert!(
10217            body.contains("\"object\":\"chat.completion.chunk\""),
10218            "missing chat chunk: {body}"
10219        );
10220        assert!(
10221            body.contains("\"usage\":{\"prompt_tokens\""),
10222            "missing final usage chunk: {body}"
10223        );
10224        assert!(
10225            body.contains("\"choices\":[],\"usage\""),
10226            "usage should be emitted as a separate chunk: {body}"
10227        );
10228        assert!(
10229            body.contains("\"prompt_tokens\":5"),
10230            "stream usage should come from engine token usage: {body}"
10231        );
10232    }
10233
10234    #[tokio::test]
10235    async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
10236        let response = post_json(
10237            router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
10238            "/v1/chat/completions",
10239            json!({
10240                "model": "stub-model",
10241                "messages": [{"role": "user", "content": "Say hello"}],
10242                "max_tokens": 2,
10243                "stream": true,
10244                "stream_options": {"include_usage": true}
10245            }),
10246        )
10247        .await;
10248        assert_eq!(response.status(), AxumStatusCode::OK);
10249        let body = response_text(response).await;
10250        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10251        assert!(
10252            body.contains("\"content\":\"he\""),
10253            "missing first chunk: {body}"
10254        );
10255        assert!(
10256            body.contains("\"content\":\"llo\""),
10257            "missing second chunk: {body}"
10258        );
10259        assert!(
10260            body.contains("\"choices\":[],\"usage\""),
10261            "missing separate usage chunk from final engine chunk: {body}"
10262        );
10263        assert!(
10264            body.contains("\"prompt_tokens\":5"),
10265            "stream usage should come from engine final usage: {body}"
10266        );
10267    }
10268
10269    #[tokio::test]
10270    async fn route_streaming_preserves_tokenless_tail_before_terminal() {
10271        for (path, chunks, expected_content, expected_reasoning) in [
10272            ("/v1/chat/completions", ["hello ", "尾"], "hello 尾", ""),
10273            (
10274                "/v1/chat/completions",
10275                ["<think>reason", "</think>"],
10276                "",
10277                "reason",
10278            ),
10279            ("/v1/completions", ["hello ", "尾"], "hello 尾", ""),
10280        ] {
10281            let chat = path == "/v1/chat/completions";
10282            let mut request = json!({"model": "stub-model", "stream": true});
10283            if chat {
10284                request["messages"] = json!([{"role": "user", "content": "hello"}]);
10285                request["stream_options"] = json!({"include_usage": true});
10286            } else {
10287                request["prompt"] = json!("hello");
10288            }
10289            let router = AxumServer::from_llm(Arc::new(StubLlm::with_tokenless_tail(&chunks)))
10290                .build_router();
10291            let response = post_json(router, path, request).await;
10292            assert_eq!(response.status(), AxumStatusCode::OK);
10293            let body = response_text(response).await;
10294            let events = responses_sse_json_events(&body);
10295            let content: String = events
10296                .iter()
10297                .filter_map(|event| {
10298                    if chat {
10299                        event["choices"][0]["delta"]["content"].as_str()
10300                    } else {
10301                        event["choices"][0]["text"].as_str()
10302                    }
10303                })
10304                .collect();
10305            let reasoning: String = events
10306                .iter()
10307                .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
10308                .collect();
10309            assert_eq!(content, expected_content, "body: {body}");
10310            assert_eq!(reasoning, expected_reasoning, "body: {body}");
10311            assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10312            assert_eq!(
10313                events
10314                    .iter()
10315                    .filter(|event| event["choices"][0]["finish_reason"] == "stop")
10316                    .count(),
10317                1,
10318                "body: {body}"
10319            );
10320            let usage: Vec<_> = events
10321                .iter()
10322                .filter_map(|event| event["usage"].as_object())
10323                .collect();
10324            assert_eq!(usage.len(), 1, "body: {body}");
10325            assert_eq!(usage[0]["prompt_tokens"], 5);
10326            assert_eq!(usage[0]["completion_tokens"], 2);
10327        }
10328    }
10329
10330    #[tokio::test]
10331    async fn route_rejects_multimodal_content_with_400() {
10332        for content in [
10333            json!([{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}]),
10334            json!([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]),
10335            json!([{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}]),
10336            json!([
10337                {"type": "text", "text": "describe this"},
10338                {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
10339            ]),
10340        ] {
10341            let response = post_json(
10342                router_with_stub("unused"),
10343                "/v1/chat/completions",
10344                json!({
10345                    "model": "stub-model",
10346                    "messages": [{"role": "user", "content": content}]
10347                }),
10348            )
10349            .await;
10350            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10351            let body = response_json(response).await;
10352            assert_eq!(body["error"]["type"], "invalid_request_error");
10353            let message = body["error"]["message"].as_str().unwrap();
10354            assert!(message.contains("invalid chat completions request"));
10355            assert!(
10356                message.contains("unsupported message content part type"),
10357                "body: {body}"
10358            );
10359        }
10360    }
10361
10362    #[tokio::test]
10363    async fn route_rejects_non_object_stream_options() {
10364        for stream_options in [json!([]), json!("yes"), json!(42), json!(true)] {
10365            let response = post_json(
10366                router_with_stub("unused"),
10367                "/v1/chat/completions",
10368                json!({
10369                    "model": "stub-model",
10370                    "messages": [{"role": "user", "content": "hello"}],
10371                    "stream": true,
10372                    "stream_options": stream_options
10373                }),
10374            )
10375            .await;
10376            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10377            let body = response_json(response).await;
10378            assert_eq!(body["error"]["type"], "invalid_request_error");
10379            assert!(
10380                body["error"]["message"]
10381                    .as_str()
10382                    .unwrap_or_default()
10383                    .contains("stream_options must be a JSON object"),
10384                "body: {body}"
10385            );
10386        }
10387    }
10388
10389    #[tokio::test]
10390    async fn route_accepts_text_only_content_array() {
10391        let response = post_json(
10392            router_with_stub("ok"),
10393            "/v1/chat/completions",
10394            json!({
10395                "model": "stub-model",
10396                "messages": [{
10397                    "role": "user",
10398                    "content": [
10399                        {"type": "text", "text": "say"},
10400                        {"type": "text", "text": "ok"}
10401                    ]
10402                }]
10403            }),
10404        )
10405        .await;
10406        assert_eq!(response.status(), AxumStatusCode::OK);
10407        let body = response_json(response).await;
10408        assert_eq!(body["choices"][0]["message"]["content"], "ok");
10409    }
10410
10411    #[tokio::test]
10412    async fn route_chat_invalid_json_maps_to_openai_error() {
10413        let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
10414        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10415        let body = response_json(response).await;
10416        assert_eq!(body["error"]["type"], "invalid_request_error");
10417        assert_eq!(body["error"]["param"], Value::Null);
10418        assert!(body["error"]["message"]
10419            .as_str()
10420            .unwrap()
10421            .contains("invalid chat completions request"));
10422    }
10423
10424    #[tokio::test]
10425    async fn route_rejects_logit_bias_with_openai_error_param() {
10426        let response = post_json(
10427            router_with_stub("unused"),
10428            "/v1/chat/completions",
10429            json!({
10430                "model": "stub-model",
10431                "messages": [{"role": "user", "content": "hello"}],
10432                "logit_bias": {"1": 42.0}
10433            }),
10434        )
10435        .await;
10436        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10437        let body = response_json(response).await;
10438        assert_eq!(body["error"]["type"], "invalid_request_error");
10439        assert_eq!(body["error"]["param"], "logit_bias");
10440    }
10441
10442    #[tokio::test]
10443    async fn route_tool_request_reaches_engine_structured_boundary() {
10444        for stream in [false, true] {
10445            let (router, engine) = router_with_capturing_llm();
10446            let response = post_json(
10447                router,
10448                "/v1/chat/completions",
10449                json!({
10450                    "model": "qwen3",
10451                    "messages": [
10452                        {"role": "user", "content": "Use the weather tool."},
10453                        {
10454                            "role": "assistant",
10455                            "content": null,
10456                            "tool_calls": [{
10457                                "id": "call_1",
10458                                "type": "function",
10459                                "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10460                            }]
10461                        },
10462                        {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
10463                    ],
10464                    "tools": [{
10465                        "type": "function",
10466                        "function": {
10467                            "name": "weather",
10468                            "description": "Get weather",
10469                            "parameters": {
10470                                "type": "object",
10471                                "properties": {"city": {"type": "string"}},
10472                                "required": ["city"]
10473                            }
10474                        }
10475                    }],
10476                    "tool_choice": "auto",
10477                    "functions": [{
10478                        "name": "legacy_weather",
10479                        "parameters": {"type": "object", "properties": {}}
10480                    }],
10481                    "function_call": "auto",
10482                    "stream": stream
10483                }),
10484            )
10485            .await;
10486            assert_eq!(response.status(), AxumStatusCode::OK);
10487
10488            if stream {
10489                let body = response_text(response).await;
10490                assert!(body.contains("[DONE]"), "{body}");
10491                assert!(body.contains("captured"), "{body}");
10492            } else {
10493                let body = response_json(response).await;
10494                assert_eq!(body["choices"][0]["message"]["content"], "captured");
10495                assert_eq!(body["choices"][0]["finish_reason"], "stop");
10496            }
10497            let request = engine.last_request();
10498            assert!(request.prompt.contains("\"tools\":[{"));
10499            assert!(request.prompt.contains("\"type\":\"function\""));
10500            assert!(request.prompt.contains("\"name\":\"weather\""));
10501            assert!(request.prompt.contains("<|im_start|>assistant\n{"));
10502            assert!(request.prompt.contains("\"tool_calls\":[{"));
10503            assert!(request.prompt.contains("\"id\":\"call_1\""));
10504            assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
10505            assert_eq!(
10506                request.metadata["openai_tools"][0]["function"]["name"],
10507                "weather"
10508            );
10509            assert_eq!(request.metadata["openai_tool_choice"], "auto");
10510            assert_eq!(
10511                request.metadata["openai_legacy_functions"][0]["name"],
10512                "legacy_weather"
10513            );
10514            assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
10515            let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10516                panic!("expected structured chat api_request");
10517            };
10518            assert_eq!(api.messages.len(), 3);
10519            assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
10520            assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
10521            assert_eq!(api.messages[1].tool_calls[0].id, "call_1");
10522            assert_eq!(api.messages[1].tool_calls[0].function.name, "weather");
10523            assert_eq!(api.messages[2].content, "sunny");
10524            assert_eq!(api.tools[0].function.name, "weather");
10525            assert_eq!(api.legacy_functions[0].name, "legacy_weather");
10526            assert_eq!(
10527                api.messages[1].tool_calls[0].function.arguments,
10528                "{\"city\":\"Paris\"}"
10529            );
10530        }
10531    }
10532
10533    #[tokio::test]
10534    async fn route_replays_reasoning_content_in_qwen36_tool_history_sync() {
10535        let compatibility = capture_qwen36_tool_history_request(
10536            json!({"reasoning_content": "opencode-reasoning-marker"}),
10537            false,
10538        )
10539        .await;
10540        let canonical = capture_qwen36_tool_history_request(
10541            json!({"reasoning": "opencode-reasoning-marker"}),
10542            false,
10543        )
10544        .await;
10545
10546        assert_eq!(compatibility.prompt, canonical.prompt);
10547        assert!(
10548            compatibility.prompt.contains("opencode-reasoning-marker"),
10549            "Qwen3.6 prompt dropped assistant reasoning history: {}",
10550            compatibility.prompt
10551        );
10552        let message = &compatibility.metadata["openai_messages"][1];
10553        assert_eq!(message["reasoning"], "opencode-reasoning-marker");
10554        assert!(message.get("reasoning_content").is_none());
10555    }
10556
10557    #[tokio::test]
10558    async fn route_replays_reasoning_content_in_qwen36_tool_history_stream() {
10559        let request = capture_qwen36_tool_history_request(
10560            json!({"reasoning_content": "opencode-stream-reasoning-marker"}),
10561            true,
10562        )
10563        .await;
10564        assert!(
10565            request.prompt.contains("opencode-stream-reasoning-marker"),
10566            "Qwen3.6 streaming prompt dropped assistant reasoning history: {}",
10567            request.prompt
10568        );
10569    }
10570
10571    #[tokio::test]
10572    async fn route_prefers_canonical_reasoning_in_qwen36_tool_history() {
10573        for stream in [false, true] {
10574            for reasoning in ["canonical-history-marker", ""] {
10575                let request = capture_qwen36_tool_history_request(
10576                    json!({
10577                        "reasoning": reasoning,
10578                        "reasoning_content": "alias-history-marker"
10579                    }),
10580                    stream,
10581                )
10582                .await;
10583                let canonical =
10584                    capture_qwen36_tool_history_request(json!({"reasoning": reasoning}), stream)
10585                        .await;
10586                assert_eq!(request.prompt, canonical.prompt);
10587                assert!(!request.prompt.contains("alias-history-marker"));
10588                let message = &request.metadata["openai_messages"][1];
10589                assert_eq!(message["reasoning"], reasoning);
10590                assert!(message.get("reasoning_content").is_none());
10591            }
10592        }
10593    }
10594
10595    #[tokio::test]
10596    async fn route_does_not_force_reasoning_into_templates_that_ignore_it() {
10597        let template = ModelChatTemplate::new(
10598            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}",
10599            "content-only-template",
10600        );
10601        let (router, engine) = router_with_capturing_llm_and_template(template);
10602        let response = post_json(
10603            router,
10604            "/v1/chat/completions",
10605            json!({
10606                "model": "served-alias",
10607                "messages": [
10608                    {"role": "user", "content": "hello"},
10609                    {
10610                        "role": "assistant",
10611                        "content": "visible answer",
10612                        "reasoning_content": "hidden-reasoning-marker"
10613                    },
10614                    {"role": "user", "content": "continue"}
10615                ]
10616            }),
10617        )
10618        .await;
10619        assert_eq!(response.status(), AxumStatusCode::OK);
10620
10621        let request = engine.last_request();
10622        assert!(request.prompt.contains("visible answer"));
10623        assert!(!request.prompt.contains("hidden-reasoning-marker"));
10624    }
10625
10626    #[tokio::test]
10627    async fn route_tool_request_prefers_model_chat_template() {
10628        for stream in [false, true] {
10629            let template = ModelChatTemplate::new(
10630            "{% 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 id=\"{{ tool_call.id }}\">{{ 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 %}",
10631            "tool-template",
10632        );
10633            let (router, engine) = router_with_capturing_llm_and_template(template);
10634            let response = post_json(
10635            router,
10636            "/v1/chat/completions",
10637            json!({
10638                "model": "served-alias",
10639                "messages": [
10640                    {"role": "user", "content": "Use the weather tool."},
10641                    {
10642                        "role": "assistant",
10643                        "content": null,
10644                        "tool_calls": [{
10645                            "id": "weather_paris",
10646                            "type": "function",
10647                            "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10648                        }, {
10649                            "id": "weather_rome",
10650                            "type": "function",
10651                            "function": {"name": "weather", "arguments": "{\"city\":\"Rome\"}"}
10652                        }]
10653                    },
10654                    // Results may arrive in a different order than calls. IDs,
10655                    // rather than positions or function names, preserve pairing.
10656                    {"role": "tool", "tool_call_id": "weather_rome", "content": "rainy"},
10657                    {"role": "tool", "tool_call_id": "weather_paris", "content": "sunny"}
10658                ],
10659                "tools": [{
10660                    "type": "function",
10661                    "function": {
10662                        "name": "weather",
10663                        "description": "Get weather",
10664                        "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
10665                    }
10666                }],
10667                "tool_choice": "auto",
10668                "stream": stream
10669            }),
10670        )
10671        .await;
10672            assert_eq!(response.status(), AxumStatusCode::OK);
10673
10674            if stream {
10675                let body = response_text(response).await;
10676                assert!(body.contains("[DONE]"), "{body}");
10677                assert!(body.contains("captured"), "{body}");
10678            } else {
10679                let body = response_json(response).await;
10680                assert_eq!(body["choices"][0]["message"]["content"], "captured");
10681                assert_eq!(body["choices"][0]["finish_reason"], "stop");
10682            }
10683            let request = engine.last_request();
10684            assert!(request.prompt.contains("<tools>weather</tools>"));
10685            assert!(
10686                request
10687                    .prompt
10688                    .contains("<tool_call id=\"weather_paris\">weather:"),
10689                "{}",
10690                request.prompt
10691            );
10692            assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
10693            assert!(request.prompt.contains("Paris"), "{}", request.prompt);
10694            assert!(request
10695                .prompt
10696                .contains("<tool_response id=\"weather_paris\">sunny</tool_response>"));
10697            assert!(request
10698                .prompt
10699                .contains("<tool_call id=\"weather_rome\">weather:"));
10700            assert!(request.prompt.contains("Rome"));
10701            assert!(request
10702                .prompt
10703                .contains("<tool_response id=\"weather_rome\">rainy</tool_response>"));
10704            assert!(request.prompt.ends_with("[assistant]"));
10705            let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10706                panic!("expected structured continuation request");
10707            };
10708            assert_eq!(api.messages.len(), 4);
10709            assert_eq!(api.messages[1].tool_calls.len(), 2);
10710            for (call, id, city) in [
10711                (&api.messages[1].tool_calls[0], "weather_paris", "Paris"),
10712                (&api.messages[1].tool_calls[1], "weather_rome", "Rome"),
10713            ] {
10714                assert_eq!(call.id, id);
10715                assert_eq!(call.function.name, "weather");
10716                let args: Value = serde_json::from_str(&call.function.arguments).unwrap();
10717                assert_eq!(args, json!({"city": city}));
10718                let prefix = format!("<tool_call id=\"{id}\">weather:");
10719                let rendered_arguments = request
10720                    .prompt
10721                    .split_once(&prefix)
10722                    .unwrap()
10723                    .1
10724                    .split_once("</tool_call>")
10725                    .unwrap()
10726                    .0;
10727                let rendered: Value = serde_json::from_str(rendered_arguments).unwrap();
10728                assert_eq!(
10729                    rendered,
10730                    json!({"city": city}),
10731                    "tool arguments lost their call ID binding"
10732                );
10733            }
10734            for (message, id, content) in [
10735                (&api.messages[2], "weather_rome", "rainy"),
10736                (&api.messages[3], "weather_paris", "sunny"),
10737            ] {
10738                assert_eq!(message.role, ferrum_types::ApiMessageRole::Tool);
10739                assert_eq!(message.tool_call_id.as_deref(), Some(id));
10740                assert_eq!(message.content, content);
10741            }
10742            assert!(
10743                !request.prompt.contains("<|assistant|>"),
10744                "model-template tool prompt should not use generic fallback: {}",
10745                request.prompt
10746            );
10747            assert!(
10748                !request.prompt.contains("When a tool is needed"),
10749                "model-template tool prompt should not inject fallback tool instructions: {}",
10750                request.prompt
10751            );
10752        }
10753    }
10754
10755    #[tokio::test]
10756    async fn chat_omitted_output_budget_uses_auto_ceiling() {
10757        let (router, engine) = router_with_capturing_llm();
10758        let response = post_json(
10759            router,
10760            "/v1/chat/completions",
10761            json!({
10762                "model": "stub-model",
10763                "messages": [{"role": "user", "content": "hello"}]
10764            }),
10765        )
10766        .await;
10767        assert_eq!(response.status(), AxumStatusCode::OK);
10768
10769        let request = engine.last_request();
10770        assert_eq!(request.sampling_params.max_tokens, 4096);
10771        assert_eq!(
10772            request.metadata.get(DEFAULT_MAX_TOKENS_METADATA_KEY),
10773            Some(&serde_json::json!(true))
10774        );
10775    }
10776
10777    #[tokio::test]
10778    async fn chat_accepts_stop_string_and_max_completion_tokens() {
10779        let (router, engine) = router_with_capturing_llm();
10780        let response = post_json(
10781            router,
10782            "/v1/chat/completions",
10783            json!({
10784                "model": "stub-model",
10785                "messages": [{"role": "user", "content": "hello"}],
10786                "max_tokens": 99,
10787                "max_completion_tokens": 3,
10788                "stop": "<END>"
10789            }),
10790        )
10791        .await;
10792        assert_eq!(response.status(), AxumStatusCode::OK);
10793
10794        let request = engine.last_request();
10795        let defaults = default_chat_sampling_params();
10796        assert_eq!(request.sampling_params.max_tokens, 3);
10797        assert!(!request
10798            .metadata
10799            .contains_key(DEFAULT_MAX_TOKENS_METADATA_KEY));
10800        assert_eq!(request.sampling_params.temperature, defaults.temperature);
10801        assert_eq!(
10802            request.sampling_params.repetition_penalty,
10803            defaults.repetition_penalty
10804        );
10805        assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
10806    }
10807
10808    #[tokio::test]
10809    async fn chat_maps_vllm_sampling_extensions_without_hidden_defaults() {
10810        let (router, engine) = router_with_capturing_llm();
10811        let response = post_json(
10812            router,
10813            "/v1/chat/completions",
10814            json!({
10815                "model": "stub-model",
10816                "messages": [{"role": "user", "content": "hello"}],
10817                "top_k": 20,
10818                "min_p": 0.05,
10819                "repetition_penalty": 1.25
10820            }),
10821        )
10822        .await;
10823        assert_eq!(response.status(), AxumStatusCode::OK);
10824
10825        let request = engine.last_request();
10826        assert_eq!(request.sampling_params.top_k, Some(20));
10827        assert_eq!(request.sampling_params.min_p, Some(0.05));
10828        assert_eq!(request.sampling_params.repetition_penalty, 1.25);
10829    }
10830
10831    #[tokio::test]
10832    async fn chat_normalizes_disabled_sampling_extensions_and_rejects_invalid_ranges() {
10833        let (router, engine) = router_with_capturing_llm();
10834        let response = post_json(
10835            router,
10836            "/v1/chat/completions",
10837            json!({
10838                "model": "stub-model",
10839                "messages": [{"role": "user", "content": "hello"}],
10840                "top_k": -1,
10841                "min_p": 0.0,
10842                "repetition_penalty": 1.0
10843            }),
10844        )
10845        .await;
10846        assert_eq!(response.status(), AxumStatusCode::OK);
10847        let request = engine.last_request();
10848        assert_eq!(request.sampling_params.top_k, None);
10849        assert_eq!(request.sampling_params.min_p, None);
10850        assert_eq!(request.sampling_params.repetition_penalty, 1.0);
10851
10852        for (field, value) in [
10853            ("top_k", json!(-2)),
10854            ("min_p", json!(1.01)),
10855            ("repetition_penalty", json!(0.0)),
10856            ("presence_penalty", json!(2.01)),
10857            ("frequency_penalty", json!(-2.01)),
10858        ] {
10859            let (router, _) = router_with_capturing_llm();
10860            let response = post_json(
10861                router,
10862                "/v1/chat/completions",
10863                json!({
10864                    "model": "stub-model",
10865                    "messages": [{"role": "user", "content": "hello"}],
10866                    (field): value
10867                }),
10868            )
10869            .await;
10870            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST, "{field}");
10871            let body = response_json(response).await;
10872            assert_eq!(body["error"]["param"], field);
10873        }
10874    }
10875
10876    #[tokio::test]
10877    async fn chat_request_forbids_initial_think_close_token() {
10878        let engine = Arc::new(CapturingLlm::new());
10879        let router = AxumServer::from_llm(engine.clone()).build_router();
10880        let response = post_json(
10881            router,
10882            "/v1/chat/completions",
10883            json!({
10884                "model": "qwen3",
10885                "messages": [{"role": "user", "content": "hello"}]
10886            }),
10887        )
10888        .await;
10889        assert_eq!(response.status(), AxumStatusCode::OK);
10890
10891        let request = engine.last_request();
10892        assert_eq!(
10893            request
10894                .metadata
10895                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10896            Some(&serde_json::json!([THINK_END_TAG]))
10897        );
10898    }
10899
10900    #[tokio::test]
10901    async fn omitted_enable_thinking_preserves_model_template_default() {
10902        let template = ModelChatTemplate::new(
10903            "{% 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 %}",
10904            "test-template",
10905        );
10906        let (router, engine) = router_with_capturing_llm_and_template(template);
10907        let response = post_json(
10908            router,
10909            "/v1/chat/completions",
10910            json!({
10911                "model": "served-alias",
10912                "messages": [{"role": "user", "content": "hello"}]
10913            }),
10914        )
10915        .await;
10916        assert_eq!(response.status(), AxumStatusCode::OK);
10917
10918        let request = engine.last_request();
10919        assert!(request.prompt.ends_with("<|im_start|>assistant\n<think>\n"));
10920        assert!(!request
10921            .metadata
10922            .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10923    }
10924
10925    #[tokio::test]
10926    async fn server_thinking_default_applies_but_request_override_wins() {
10927        let template = ModelChatTemplate::new(
10928            "{% 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 %}",
10929            "test-template",
10930        );
10931        let (router, engine) =
10932            router_with_capturing_llm_and_template_default(template, Some(false));
10933
10934        let response = post_json(
10935            router.clone(),
10936            "/v1/chat/completions",
10937            json!({
10938                "model": "served-alias",
10939                "messages": [{"role": "user", "content": "hello"}]
10940            }),
10941        )
10942        .await;
10943        assert_eq!(response.status(), AxumStatusCode::OK);
10944        let request = engine.last_request();
10945        assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
10946        assert_eq!(
10947            request
10948                .metadata
10949                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10950            Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
10951        );
10952
10953        let response = post_json(
10954            router,
10955            "/v1/chat/completions",
10956            json!({
10957                "model": "served-alias",
10958                "messages": [{"role": "user", "content": "hello"}],
10959                "chat_template_kwargs": {"enable_thinking": true}
10960            }),
10961        )
10962        .await;
10963        assert_eq!(response.status(), AxumStatusCode::OK);
10964        let request = engine.last_request();
10965        assert_eq!(request.prompt, "<assistant><think>\n");
10966        assert!(!request
10967            .metadata
10968            .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10969    }
10970
10971    #[tokio::test]
10972    async fn chat_template_enable_thinking_true_overrides_default() {
10973        let template = ModelChatTemplate::new(
10974            "{% 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 %}",
10975            "test-template",
10976        );
10977        let (router, engine) = router_with_capturing_llm_and_template(template);
10978        let response = post_json(
10979            router,
10980            "/v1/chat/completions",
10981            json!({
10982                "model": "served-alias",
10983                "messages": [{"role": "user", "content": "hello"}],
10984                "chat_template_kwargs": {"enable_thinking": true}
10985            }),
10986        )
10987        .await;
10988        assert_eq!(response.status(), AxumStatusCode::OK);
10989
10990        let request = engine.last_request();
10991        assert_eq!(request.prompt, "<assistant><think>\n");
10992        assert!(!request
10993            .metadata
10994            .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10995    }
10996
10997    #[tokio::test]
10998    async fn chat_template_enable_thinking_false_is_a_hard_override() {
10999        let template = ModelChatTemplate::new(
11000            "{% 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 %}",
11001            "test-template",
11002        );
11003        let (router, engine) = router_with_capturing_llm_and_template(template);
11004        let response = post_json(
11005            router,
11006            "/v1/chat/completions",
11007            json!({
11008                "model": "served-alias",
11009                "messages": [{"role": "user", "content": "hello"}],
11010                "chat_template_kwargs": {"enable_thinking": false}
11011            }),
11012        )
11013        .await;
11014        assert_eq!(response.status(), AxumStatusCode::OK);
11015
11016        let request = engine.last_request();
11017        assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
11018        assert_eq!(
11019            request
11020                .metadata
11021                .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
11022            Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
11023        );
11024    }
11025
11026    #[tokio::test]
11027    async fn chat_template_reasoning_effort_is_typed_and_rendered() {
11028        let template = ModelChatTemplate::new(
11029            "{% if reasoning_effort is defined %}Reasoning: {{ reasoning_effort }}{% else %}Reasoning: model-default{% endif %}",
11030            "test-template",
11031        );
11032        let (router, engine) = router_with_capturing_llm_and_template(template);
11033        let response = post_json(
11034            router.clone(),
11035            "/v1/chat/completions",
11036            json!({
11037                "model": "served-alias",
11038                "messages": [{"role": "user", "content": "hello"}],
11039                "chat_template_kwargs": {"reasoning_effort": "low"}
11040            }),
11041        )
11042        .await;
11043        assert_eq!(response.status(), AxumStatusCode::OK);
11044        assert_eq!(engine.last_request().prompt, "Reasoning: low");
11045
11046        let response = post_json(
11047            router.clone(),
11048            "/v1/chat/completions",
11049            json!({
11050                "model": "served-alias",
11051                "messages": [{"role": "user", "content": "hello"}],
11052                "chat_template_kwargs": {"reasoning_effort": "xhigh"}
11053            }),
11054        )
11055        .await;
11056        assert_eq!(response.status(), AxumStatusCode::OK);
11057        assert_eq!(engine.last_request().prompt, "Reasoning: xhigh");
11058
11059        for invalid in [json!("extreme"), json!(1)] {
11060            let response = post_json(
11061                router.clone(),
11062                "/v1/chat/completions",
11063                json!({
11064                    "model": "served-alias",
11065                    "messages": [{"role": "user", "content": "hello"}],
11066                    "chat_template_kwargs": {"reasoning_effort": invalid}
11067                }),
11068            )
11069            .await;
11070            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11071            let body = response_json(response).await;
11072            assert_eq!(body["error"]["type"], "invalid_request_error");
11073            assert!(body["error"]["message"]
11074                .as_str()
11075                .unwrap_or_default()
11076                .contains("reasoning_effort"));
11077        }
11078    }
11079
11080    #[tokio::test]
11081    async fn chat_template_enable_thinking_rejects_non_bool() {
11082        let template = ModelChatTemplate::new(
11083            "{% if add_generation_prompt %}<assistant>{% endif %}",
11084            "test-template",
11085        );
11086        let (router, _) = router_with_capturing_llm_and_template(template);
11087        let response = post_json(
11088            router,
11089            "/v1/chat/completions",
11090            json!({
11091                "model": "served-alias",
11092                "messages": [{"role": "user", "content": "hello"}],
11093                "chat_template_kwargs": {"enable_thinking": "false"}
11094            }),
11095        )
11096        .await;
11097        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11098        let body = response_json(response).await;
11099        assert_eq!(body["error"]["type"], "invalid_request_error");
11100        assert!(body["error"]["message"]
11101            .as_str()
11102            .unwrap_or_default()
11103            .contains("chat_template_kwargs.enable_thinking must be a boolean"));
11104    }
11105
11106    #[tokio::test]
11107    async fn stop_string_strips_chat_and_completion_suffixes() {
11108        let chat = post_json(
11109            router_with_stub("hello<END>"),
11110            "/v1/chat/completions",
11111            json!({
11112                "model": "stub-model",
11113                "messages": [{"role": "user", "content": "hello"}],
11114                "stop": "<END>"
11115            }),
11116        )
11117        .await;
11118        assert_eq!(chat.status(), AxumStatusCode::OK);
11119        let chat_body = response_json(chat).await;
11120        assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
11121
11122        let completion = post_json(
11123            router_with_stub("done<END>"),
11124            "/v1/completions",
11125            json!({
11126                "model": "stub-model",
11127                "prompt": "complete",
11128                "stop": "<END>"
11129            }),
11130        )
11131        .await;
11132        assert_eq!(completion.status(), AxumStatusCode::OK);
11133        let completion_body = response_json(completion).await;
11134        assert_eq!(completion_body["choices"][0]["text"], "done");
11135    }
11136
11137    #[test]
11138    fn started_in_think_parse_streams_reasoning_before_end_tag() {
11139        // R1-distill templates open `<think>` inside the prompt, so the
11140        // generated text never contains the start tag. Mid-think text must
11141        // be reasoning, not content (this leaked as content deltas before).
11142        let parsed = parse_reasoning_response_started_in_think("Okay, the user wants");
11143        assert_eq!(parsed.reasoning.as_deref(), Some("Okay, the user wants"));
11144        assert_eq!(parsed.content, "");
11145
11146        let parsed = parse_reasoning_response_started_in_think("thinking...</think>\nanswer");
11147        assert_eq!(parsed.reasoning.as_deref(), Some("thinking..."));
11148        assert_eq!(parsed.content, "answer");
11149
11150        // Model re-opening its own think block defers to the normal parse.
11151        let parsed = parse_reasoning_response_started_in_think("<think>\nx\n</think>\n\nanswer");
11152        assert_eq!(parsed.reasoning.as_deref(), Some("\nx\n"));
11153        assert_eq!(parsed.content, "answer");
11154    }
11155
11156    #[tokio::test]
11157    async fn chat_response_splits_reasoning_from_content() {
11158        let response = post_json(
11159            router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
11160            "/v1/chat/completions",
11161            json!({
11162                "model": "stub-model",
11163                "messages": [{"role": "user", "content": "hello"}]
11164            }),
11165        )
11166        .await;
11167        assert_eq!(response.status(), AxumStatusCode::OK);
11168
11169        let body = response_json(response).await;
11170        let message = &body["choices"][0]["message"];
11171        assert_eq!(message["content"], "final answer");
11172        assert_eq!(message["reasoning"], "\nreasoning\n");
11173        assert!(message.get("reasoning_content").is_none());
11174    }
11175
11176    #[tokio::test]
11177    async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
11178        let response = post_json(
11179            router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
11180            "/v1/chat/completions",
11181            json!({
11182                "model": "stub-model",
11183                "messages": [{"role": "user", "content": "think then answer"}],
11184                "stream": true
11185            }),
11186        )
11187        .await;
11188        assert_eq!(response.status(), AxumStatusCode::OK);
11189        let body = response_text(response).await;
11190        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11191        assert!(
11192            body.contains(r#""reasoning":"\nreason"#),
11193            "stream should emit reasoning delta after full think prefix: {body}"
11194        );
11195        assert!(!body.contains("\"reasoning_content\":"));
11196        assert!(
11197            body.contains(r#""content":"final""#),
11198            "stream should emit visible content after think close: {body}"
11199        );
11200        assert!(
11201            !body.contains(r#""content":"<"#),
11202            "partial think prefix must not leak as content: {body}"
11203        );
11204    }
11205
11206    #[tokio::test]
11207    async fn route_rejects_unsupported_tool_and_function_selection() {
11208        for (extra, param) in [
11209            (
11210                json!({
11211                    "tools": [{
11212                        "type": "function",
11213                        "function": {"name": "weather", "parameters": {"type": "object"}}
11214                    }],
11215                    "tool_choice": {
11216                        "type": "function",
11217                        "function": {"name": "calendar"}
11218                    }
11219                }),
11220                "tool_choice",
11221            ),
11222            (
11223                json!({
11224                    "functions": [{"name": "weather", "parameters": {"type": "object"}}],
11225                    "function_call": {"name": "calendar"}
11226                }),
11227                "function_call",
11228            ),
11229        ] {
11230            let mut body = json!({
11231                "model": "stub-model",
11232                "messages": [{"role": "user", "content": "hello"}]
11233            });
11234            body.as_object_mut()
11235                .expect("object")
11236                .extend(extra.as_object().expect("extra object").clone());
11237            let response =
11238                post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
11239            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11240            let body = response_json(response).await;
11241            assert_eq!(body["error"]["type"], "invalid_request_error");
11242            assert_eq!(body["error"]["param"], param);
11243        }
11244    }
11245
11246    #[tokio::test]
11247    async fn route_rejects_non_function_tools_with_openai_error_param() {
11248        let response = post_json(
11249            router_with_stub("unused"),
11250            "/v1/chat/completions",
11251            json!({
11252                "model": "stub-model",
11253                "messages": [{"role": "user", "content": "hello"}],
11254                "tools": [{
11255                    "type": "retrieval",
11256                    "function": {"name": "search", "parameters": {"type": "object"}}
11257                }]
11258            }),
11259        )
11260        .await;
11261        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11262        let body = response_json(response).await;
11263        assert_eq!(body["error"]["type"], "invalid_request_error");
11264        assert_eq!(body["error"]["param"], "tools");
11265    }
11266
11267    #[tokio::test]
11268    async fn route_rejects_tool_choice_required_without_tools() {
11269        let response = post_json(
11270            router_with_stub("unused"),
11271            "/v1/chat/completions",
11272            json!({
11273                "model": "stub-model",
11274                "messages": [{"role": "user", "content": "hello"}],
11275                "tool_choice": "required"
11276            }),
11277        )
11278        .await;
11279        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11280        let body = response_json(response).await;
11281        assert_eq!(body["error"]["type"], "invalid_request_error");
11282        assert_eq!(body["error"]["param"], "tool_choice");
11283    }
11284
11285    #[tokio::test]
11286    async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
11287        let response = post_json(
11288            router_with_stub("unused"),
11289            "/v1/chat/completions",
11290            json!({
11291                "model": "stub-model",
11292                "messages": [{"role": "user", "content": "hello"}],
11293                "response_format": {"type": "xml"}
11294            }),
11295        )
11296        .await;
11297        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11298        let body = response_json(response).await;
11299        assert_eq!(body["error"]["type"], "invalid_request_error");
11300        assert_eq!(body["error"]["param"], "response_format.type");
11301    }
11302
11303    #[tokio::test]
11304    async fn route_chat_engine_unavailable_maps_to_503() {
11305        let response = post_json(
11306            router_without_llm(),
11307            "/v1/chat/completions",
11308            json!({
11309                "model": "stub-model",
11310                "messages": [{"role": "user", "content": "hello"}]
11311            }),
11312        )
11313        .await;
11314        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11315        let body = response_json(response).await;
11316        assert_eq!(body["error"]["type"], "service_unavailable_error");
11317        assert_eq!(body["error"]["param"], Value::Null);
11318    }
11319
11320    #[tokio::test]
11321    async fn context_capacity_rejection_has_a_structured_code_on_openai_routes() {
11322        for stream in [false, true] {
11323            for (path, mut input) in [
11324                (
11325                    "/v1/chat/completions",
11326                    json!({"messages":[{"role":"user","content":"hello"}],"max_tokens":100}),
11327                ),
11328                (
11329                    "/v1/completions",
11330                    json!({"prompt":"hello","max_tokens":100}),
11331                ),
11332                (
11333                    "/v1/responses",
11334                    json!({"input":"hello","max_output_tokens":100}),
11335                ),
11336            ] {
11337                input["model"] = json!("failing-model");
11338                input["stream"] = json!(stream);
11339                let router = AxumServer::from_llm(Arc::new(FailingLlm::context_length_exceeded()))
11340                    .build_router();
11341                let response = post_json(router, path, input).await;
11342                assert_eq!(
11343                    response.status(),
11344                    AxumStatusCode::BAD_REQUEST,
11345                    "{path}, stream={stream}"
11346                );
11347                let body = response_json(response).await;
11348                assert_eq!(body["error"]["code"], "context_length_exceeded", "{body}");
11349                assert_eq!(body["error"]["type"], "invalid_request_error");
11350                assert!(body["error"]["message"]
11351                    .as_str()
11352                    .unwrap()
11353                    .contains("500 input tokens + 100 output tokens"));
11354            }
11355        }
11356        let ordinary =
11357            server_error_from_ferrum_error(Error::request_validation("invalid parameter"))
11358                .into_response();
11359        assert_eq!(response_json(ordinary).await["error"]["code"], Value::Null);
11360    }
11361
11362    #[tokio::test]
11363    async fn route_chat_generation_failure_maps_to_500() {
11364        let response = post_json(
11365            router_with_failing_llm(),
11366            "/v1/chat/completions",
11367            json!({
11368                "model": "failing-model",
11369                "messages": [{"role": "user", "content": "hello"}]
11370            }),
11371        )
11372        .await;
11373        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11374        let body = response_json(response).await;
11375        assert_eq!(body["error"]["type"], "internal_server_error");
11376        assert!(body["error"]["message"]
11377            .as_str()
11378            .unwrap()
11379            .contains("stub generation failed"));
11380    }
11381
11382    #[tokio::test]
11383    async fn route_chat_generation_failure_writes_replay_diagnostics() {
11384        let root = unique_request_dump_dir("chat-sync-failure");
11385        let profile = unique_profile_jsonl("chat-sync-failure");
11386        let response = post_json(
11387            router_with_failing_llm_request_dump_and_profile(root.clone(), profile.clone()),
11388            "/v1/chat/completions",
11389            json!({
11390                "model": "failing-model",
11391                "messages": [{"role": "user", "content": "hello"}]
11392            }),
11393        )
11394        .await;
11395        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11396        assert_chat_failure_replay_bundle(
11397            &root,
11398            "chat_completions_sync",
11399            "internal",
11400            "stub generation failed",
11401        );
11402        let event = read_profile_events(&profile)
11403            .into_iter()
11404            .find(|event| event["phase"] == "chat_completions_sync")
11405            .expect("sync failure profile event");
11406        assert_eq!(event["event_kind"], "timed_span");
11407        assert_eq!(event["status"], "failure");
11408        assert!(event["duration_us"].as_u64().is_some());
11409        assert_eq!(event["attributes"]["terminal_failure_event"], true);
11410        assert_eq!(event["error"]["kind"], "internal");
11411        let _ = fs::remove_dir_all(root);
11412        let _ = fs::remove_file(profile);
11413    }
11414
11415    #[tokio::test]
11416    async fn route_chat_resource_failure_writes_resource_replay_diagnostics() {
11417        let root = unique_request_dump_dir("chat-resource-failure");
11418        let response = post_json(
11419            router_with_resource_exhausted_llm_and_request_dump_dir(root.clone()),
11420            "/v1/chat/completions",
11421            json!({
11422                "model": "failing-model",
11423                "messages": [{"role": "user", "content": "hello"}]
11424            }),
11425        )
11426        .await;
11427        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11428        let bundle = only_replay_bundle(&root);
11429        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11430        assert_eq!(bad_scan["failure_kind"], "oom_admission");
11431        let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
11432        assert_eq!(diagnostics["failure_kind"], "oom_admission");
11433        assert_eq!(
11434            diagnostics["first_failure_event"]["error_kind"],
11435            "resource_exhausted"
11436        );
11437        assert_eq!(
11438            diagnostics["capacity"]["resource_kind"],
11439            "admission_capacity"
11440        );
11441        assert!(diagnostics["capacity"]["reason"]
11442            .as_str()
11443            .expect("capacity reason")
11444            .contains("admission capacity exhausted"));
11445        assert_eq!(
11446            diagnostics["nearest_resource_event"]["resource_kind"],
11447            "admission_capacity"
11448        );
11449        assert!(diagnostics["nearest_memory_snapshot"]["current_bytes"].is_number());
11450        assert!(diagnostics["nearest_memory_snapshot"]["high_water_bytes"].is_number());
11451        let _ = fs::remove_dir_all(root);
11452    }
11453
11454    #[tokio::test]
11455    async fn route_chat_sync_success_updates_replay_output_tokens() {
11456        let root = unique_request_dump_dir("chat-sync-success-output");
11457        let response = post_json(
11458            router_with_stub_and_request_dump_dir("OK", root.clone()),
11459            "/v1/chat/completions",
11460            json!({
11461                "model": "stub-model",
11462                "messages": [{"role": "user", "content": "hello"}]
11463            }),
11464        )
11465        .await;
11466        assert_eq!(response.status(), AxumStatusCode::OK);
11467        let body = response_json(response).await;
11468        assert_eq!(body["choices"][0]["message"]["content"], "OK");
11469        assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11470        let _ = fs::remove_dir_all(root);
11471    }
11472
11473    #[tokio::test]
11474    async fn route_chat_sync_success_writes_product_profile_event() {
11475        let root = unique_request_dump_dir("chat-sync-profile");
11476        let profile = unique_profile_jsonl("chat-sync-profile");
11477        let response = post_json(
11478            router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11479            "/v1/chat/completions",
11480            json!({
11481                "model": "stub-model",
11482                "messages": [{"role": "user", "content": "hello"}]
11483            }),
11484        )
11485        .await;
11486        assert_eq!(response.status(), AxumStatusCode::OK);
11487        let response_body = response_json(response).await;
11488
11489        let events = read_profile_events(&profile);
11490        assert_eq!(events.len(), 2, "events: {events:#?}");
11491        let event = events
11492            .iter()
11493            .find(|event| event["phase"] == "chat_completions_sync_complete")
11494            .expect("sync completion profile event");
11495        assert!(response_body["id"]
11496            .as_str()
11497            .is_some_and(|id| !id.is_empty()));
11498        assert_eq!(response_body["id"], event["request_id"]);
11499        assert_eq!(response_body["id"], event["correlation_id"]);
11500        assert_eq!(
11501            event["schema_version"],
11502            OBSERVABILITY_PROFILE_SCHEMA_VERSION
11503        );
11504        assert_eq!(event["entrypoint"], "serve");
11505        assert_eq!(event["event_kind"], "timed_span");
11506        assert_eq!(event["status"], "ok");
11507        assert_eq!(event["phase"], "chat_completions_sync_complete");
11508        assert_eq!(event["attributes"]["actual_model_smoke"], true);
11509        assert_eq!(event["attributes"]["profile_detail"], "latency");
11510        assert_eq!(event["attributes"]["diagnostic_only"], false);
11511        assert_eq!(event["attributes"]["stream"], false);
11512        assert_eq!(event["attributes"]["output_token_count"], 2);
11513        assert_eq!(event["attributes"]["prompt_token_count"], 7);
11514        assert_eq!(event["attributes"]["completion_token_count"], 2);
11515        assert_eq!(event["attributes"]["total_token_count"], 9);
11516        assert_eq!(event["attributes"]["token_count_source"], "usage");
11517        assert_eq!(event["attributes"]["finish_reason"], "stop");
11518        assert_eq!(
11519            event["attributes"]["engine_token_clock_source"],
11520            "rust_std_instant"
11521        );
11522        assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11523        assert_eq!(event["attributes"]["itl_interval_count"], 1);
11524        assert_eq!(event["attributes"]["ttft_us"], 1_000);
11525        assert_eq!(event["attributes"]["itl_us_avg"], 1_000);
11526        assert!(event["attributes"]["http_first_sse_enqueue_us"].is_null());
11527        assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11528        assert!(
11529            event["attributes"]["e2e_duration_us"]
11530                .as_u64()
11531                .unwrap_or_default()
11532                > 0
11533        );
11534        assert_eq!(
11535            event["replay"]["bundle_dir"].as_str(),
11536            Some(root.to_string_lossy().as_ref())
11537        );
11538        assert!(event["replay"]["command"]
11539            .as_str()
11540            .unwrap_or_default()
11541            .contains("replay_body.json"));
11542        let memory_event = events
11543            .iter()
11544            .find(|event| event["phase"] == "actual_serve_first_request_done")
11545            .expect("first request memory profile event");
11546        assert_eq!(memory_event["event_kind"], "memory");
11547        assert_eq!(
11548            memory_event["attributes"]["memory_stage"],
11549            "first_request_done"
11550        );
11551        assert_eq!(
11552            memory_event["attributes"]["memory_measurement"],
11553            "process_rss"
11554        );
11555        assert!(memory_event["memory"]["current_bytes"]
11556            .as_u64()
11557            .is_some_and(|bytes| bytes > 0));
11558        let _ = fs::remove_dir_all(root);
11559        let _ = fs::remove_file(profile);
11560    }
11561
11562    #[tokio::test]
11563    async fn route_chat_profile_events_preserve_benchmark_correlation() {
11564        let root = unique_request_dump_dir("chat-benchmark-correlation");
11565        let profile = unique_profile_jsonl("chat-benchmark-correlation");
11566        let correlation = BenchmarkRequestCorrelation::new(
11567            "bench-123".to_string(),
11568            "cell-1-closed-c8".to_string(),
11569            2,
11570            ferrum_bench_core::BenchmarkPhase::Measured,
11571            17,
11572        )
11573        .unwrap();
11574        let response = post_json_with_benchmark_correlation(
11575            router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11576            "/v1/chat/completions",
11577            json!({
11578                "model": "stub-model",
11579                "messages": [{"role": "user", "content": "hello"}]
11580            }),
11581            &correlation,
11582        )
11583        .await;
11584        assert_eq!(response.status(), AxumStatusCode::OK);
11585        let _ = response_json(response).await;
11586
11587        let events = read_profile_events(&profile);
11588        assert_eq!(events.len(), 2, "events: {events:#?}");
11589        for event in events {
11590            assert_eq!(event["attributes"]["benchmark_run_id"], "bench-123");
11591            assert_eq!(event["attributes"]["cell_id"], "cell-1-closed-c8");
11592            assert_eq!(event["attributes"]["repeat_index"], 2);
11593            assert_eq!(event["attributes"]["phase"], "measured");
11594            assert_eq!(event["attributes"]["request_index"], 17);
11595        }
11596        let _ = fs::remove_dir_all(root);
11597        let _ = fs::remove_file(profile);
11598    }
11599
11600    #[tokio::test]
11601    async fn route_chat_rejects_partial_benchmark_correlation_headers() {
11602        let response = router_with_stub("OK")
11603            .oneshot(
11604                Request::builder()
11605                    .method("POST")
11606                    .uri("/v1/chat/completions")
11607                    .header(header::CONTENT_TYPE, "application/json")
11608                    .header(BENCHMARK_RUN_ID_HEADER, "bench-123")
11609                    .body(Body::from(
11610                        json!({
11611                            "model": "stub-model",
11612                            "messages": [{"role": "user", "content": "hello"}]
11613                        })
11614                        .to_string(),
11615                    ))
11616                    .expect("request"),
11617            )
11618            .await
11619            .expect("route response");
11620        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11621    }
11622
11623    #[tokio::test]
11624    async fn route_chat_sync_profile_jsonl_is_parseable_under_concurrent_requests() {
11625        let root = unique_request_dump_dir("chat-sync-profile-concurrent");
11626        let profile = unique_profile_jsonl("chat-sync-profile-concurrent");
11627        let app = router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone());
11628
11629        let mut handles = Vec::new();
11630        for request_index in 0..8 {
11631            let app = app.clone();
11632            handles.push(tokio::spawn(async move {
11633                let response = post_json(
11634                    app,
11635                    "/v1/chat/completions",
11636                    json!({
11637                        "model": "stub-model",
11638                        "messages": [{"role": "user", "content": format!("hello {request_index}")}]
11639                    }),
11640                )
11641                .await;
11642                assert_eq!(response.status(), AxumStatusCode::OK);
11643                let body = response_json(response).await;
11644                assert_eq!(body["choices"][0]["message"]["content"], "OK");
11645            }));
11646        }
11647
11648        for handle in handles {
11649            handle.await.expect("concurrent request task");
11650        }
11651
11652        let raw = fs::read_to_string(&profile).expect("profile jsonl");
11653        let mut completion_events = 0usize;
11654        for (line_index, line) in raw
11655            .lines()
11656            .filter(|line| !line.trim().is_empty())
11657            .enumerate()
11658        {
11659            let event: Value = serde_json::from_str(line).unwrap_or_else(|err| {
11660                panic!(
11661                    "profile line {} invalid JSON: {err}: {line}",
11662                    line_index + 1
11663                )
11664            });
11665            if event["phase"] == "chat_completions_sync_complete" {
11666                completion_events += 1;
11667            }
11668        }
11669        assert_eq!(completion_events, 8);
11670        let _ = fs::remove_dir_all(root);
11671        let _ = fs::remove_file(profile);
11672    }
11673
11674    #[tokio::test]
11675    async fn route_chat_stream_success_updates_replay_output_tokens() {
11676        let root = unique_request_dump_dir("chat-stream-success-output");
11677        let response = post_json(
11678            router_with_stub_stream_chunks_and_request_dump_dir(&["O", "K"], root.clone()),
11679            "/v1/chat/completions",
11680            json!({
11681                "model": "stub-model",
11682                "messages": [{"role": "user", "content": "hello"}],
11683                "stream": true
11684            }),
11685        )
11686        .await;
11687        assert_eq!(response.status(), AxumStatusCode::OK);
11688        let body = response_text(response).await;
11689        assert!(body.contains("data: [DONE]"), "body: {body}");
11690        assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11691        let _ = fs::remove_dir_all(root);
11692    }
11693
11694    #[tokio::test]
11695    async fn route_chat_stream_success_writes_product_profile_event() {
11696        let root = unique_request_dump_dir("chat-stream-profile");
11697        let profile = unique_profile_jsonl("chat-stream-profile");
11698        let response = post_json(
11699            router_with_stub_stream_request_dump_and_profile(
11700                &["O", "K"],
11701                root.clone(),
11702                profile.clone(),
11703            ),
11704            "/v1/chat/completions",
11705            json!({
11706                "model": "stub-model",
11707                "messages": [{"role": "user", "content": "hello"}],
11708                "stream": true
11709            }),
11710        )
11711        .await;
11712        assert_eq!(response.status(), AxumStatusCode::OK);
11713        let body = response_text(response).await;
11714        assert!(body.contains("data: [DONE]"), "body: {body}");
11715
11716        let events = read_profile_events(&profile);
11717        assert_eq!(events.len(), 2, "events: {events:#?}");
11718        let event = events
11719            .iter()
11720            .find(|event| event["phase"] == "chat_completions_stream_complete")
11721            .expect("stream completion profile event");
11722        let chunks = responses_sse_json_events(&body);
11723        assert!(!chunks.is_empty());
11724        for chunk in chunks {
11725            assert!(chunk["id"].as_str().is_some_and(|id| !id.is_empty()));
11726            assert_eq!(chunk["id"], event["request_id"]);
11727            assert_eq!(chunk["id"], event["correlation_id"]);
11728        }
11729        assert_eq!(
11730            event["schema_version"],
11731            OBSERVABILITY_PROFILE_SCHEMA_VERSION
11732        );
11733        assert_eq!(event["entrypoint"], "serve");
11734        assert_eq!(event["event_kind"], "timed_span");
11735        assert_eq!(event["status"], "ok");
11736        assert_eq!(event["phase"], "chat_completions_stream_complete");
11737        assert_eq!(event["attributes"]["actual_model_smoke"], true);
11738        assert_eq!(event["attributes"]["profile_detail"], "latency");
11739        assert_eq!(event["attributes"]["diagnostic_only"], false);
11740        assert_eq!(event["attributes"]["stream"], true);
11741        assert_eq!(event["attributes"]["output_token_count"], 2);
11742        assert_eq!(event["attributes"]["prompt_token_count"], 5);
11743        assert_eq!(event["attributes"]["completion_token_count"], 2);
11744        assert_eq!(event["attributes"]["total_token_count"], 7);
11745        assert_eq!(event["attributes"]["token_count_source"], "usage");
11746        assert_eq!(event["attributes"]["finish_reason"], "stop");
11747        assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11748        assert!(
11749            event["attributes"]["e2e_duration_us"]
11750                .as_u64()
11751                .unwrap_or_default()
11752                > 0
11753        );
11754        assert!(event["attributes"]["ttft_us"].as_u64().is_some());
11755        assert!(event["attributes"]["itl_us_avg"].as_u64().is_some());
11756        assert_eq!(
11757            event["attributes"]["engine_token_commit_nanos_since_request_start"],
11758            json!([1_000_000, 2_000_000])
11759        );
11760        assert_eq!(event["attributes"]["itl_interval_count"], 1);
11761        assert_eq!(event["attributes"]["itl_source"], "engine_token_commit");
11762        assert!(event["attributes"]["engine_stream_first_chunk_received_us"]
11763            .as_u64()
11764            .is_some());
11765        assert!(event["attributes"]["http_first_sse_enqueue_us"]
11766            .as_u64()
11767            .is_some());
11768        assert!(event["attributes"]["http_stream_flush_unavailable_reason"]
11769            .as_str()
11770            .is_some());
11771        assert_eq!(
11772            event["replay"]["bundle_dir"].as_str(),
11773            Some(root.to_string_lossy().as_ref())
11774        );
11775        let memory_event = events
11776            .iter()
11777            .find(|event| event["phase"] == "actual_serve_first_request_done")
11778            .expect("first request memory profile event");
11779        assert_eq!(memory_event["event_kind"], "memory");
11780        assert_eq!(
11781            memory_event["attributes"]["memory_stage"],
11782            "first_request_done"
11783        );
11784        assert_eq!(
11785            memory_event["attributes"]["memory_measurement"],
11786            "process_rss"
11787        );
11788        assert!(memory_event["memory"]["current_bytes"]
11789            .as_u64()
11790            .is_some_and(|bytes| bytes > 0));
11791        let _ = fs::remove_dir_all(root);
11792        let _ = fs::remove_file(profile);
11793    }
11794
11795    #[tokio::test]
11796    async fn route_chat_stream_profile_retains_non_visible_terminal_token() {
11797        let root = unique_request_dump_dir("chat-stream-profile-terminal-token");
11798        let profile = unique_profile_jsonl("chat-stream-profile-terminal-token");
11799        let llm = StubLlm {
11800            stream_usage: Some(TokenUsage::new(5, 2)),
11801            ..StubLlm::with_stream_chunks(&["Paris"])
11802        };
11803        let app = AxumServer::from_state(
11804            AppState::default()
11805                .with_llm(Arc::new(llm))
11806                .with_request_dump_dir(Some(root.clone()))
11807                .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
11808                .with_profile_jsonl(Some(profile.clone())),
11809        )
11810        .build_router();
11811
11812        let response = post_json(
11813            app,
11814            "/v1/chat/completions",
11815            json!({
11816                "model": "stub-model",
11817                "messages": [{"role": "user", "content": "hello"}],
11818                "stream": true,
11819                "stream_options": {"include_usage": true}
11820            }),
11821        )
11822        .await;
11823        assert_eq!(response.status(), AxumStatusCode::OK);
11824        let body = response_text(response).await;
11825        assert!(body.contains("\"completion_tokens\":2"), "body: {body}");
11826        assert!(body.contains("data: [DONE]"), "body: {body}");
11827
11828        let events = read_profile_events(&profile);
11829        let event = events
11830            .iter()
11831            .find(|event| event["phase"] == "chat_completions_stream_complete")
11832            .expect("stream completion profile event");
11833        assert_eq!(event["attributes"]["output_token_count"], 2);
11834        assert_eq!(event["attributes"]["completion_token_count"], 2);
11835        assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11836        assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "Paris");
11837
11838        let _ = fs::remove_dir_all(root);
11839        let _ = fs::remove_file(profile);
11840    }
11841
11842    #[tokio::test]
11843    async fn route_chat_sync_bad_output_updates_replay_classifier() {
11844        let root = unique_request_dump_dir("chat-sync-bad-output");
11845        let response = post_json(
11846            router_with_stub_and_request_dump_dir("<unk>", root.clone()),
11847            "/v1/chat/completions",
11848            json!({
11849                "model": "stub-model",
11850                "messages": [{"role": "user", "content": "hello"}]
11851            }),
11852        )
11853        .await;
11854        assert_eq!(response.status(), AxumStatusCode::OK);
11855        let _ = response_json(response).await;
11856        let bundle = only_replay_bundle(&root);
11857        let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11858        assert_eq!(bad_scan["bad_output"], true);
11859        assert_eq!(bad_scan["reasons"], json!(["reserved_token"]));
11860        assert_eq!(bad_scan["first_bad_text_span"]["reason"], "reserved_token");
11861        let _ = fs::remove_dir_all(root);
11862    }
11863
11864    #[tokio::test]
11865    async fn route_chat_stream_generation_failure_emits_openai_error_event() {
11866        let response = post_json(
11867            router_with_failing_llm(),
11868            "/v1/chat/completions",
11869            json!({
11870                "model": "failing-model",
11871                "messages": [{"role": "user", "content": "hello"}],
11872                "stream": true
11873            }),
11874        )
11875        .await;
11876        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11877        let body = response_json(response).await;
11878        assert_eq!(body["error"]["type"], "internal_server_error");
11879        assert!(body["error"]["message"]
11880            .as_str()
11881            .unwrap_or_default()
11882            .contains("stub stream failed"));
11883    }
11884
11885    #[tokio::test]
11886    async fn route_chat_stream_generation_failure_writes_replay_diagnostics() {
11887        let root = unique_request_dump_dir("chat-stream-start-failure");
11888        let response = post_json(
11889            router_with_failing_llm_and_request_dump_dir(root.clone()),
11890            "/v1/chat/completions",
11891            json!({
11892                "model": "failing-model",
11893                "messages": [{"role": "user", "content": "hello"}],
11894                "stream": true
11895            }),
11896        )
11897        .await;
11898        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11899        let body = response_json(response).await;
11900        assert_eq!(body["error"]["type"], "internal_server_error");
11901        assert!(body["error"]["message"]
11902            .as_str()
11903            .unwrap_or_default()
11904            .contains("stub stream failed"));
11905        assert_chat_failure_replay_bundle(
11906            &root,
11907            "chat_completions_stream_start",
11908            "internal",
11909            "stub stream failed",
11910        );
11911        let _ = fs::remove_dir_all(root);
11912    }
11913
11914    #[tokio::test]
11915    async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
11916        let response = post_json(
11917            router_with_stream_chunk_failing_llm(),
11918            "/v1/chat/completions",
11919            json!({
11920                "model": "failing-model",
11921                "messages": [{"role": "user", "content": "hello"}],
11922                "stream": true
11923            }),
11924        )
11925        .await;
11926        assert_eq!(response.status(), AxumStatusCode::OK);
11927        let body = response_text(response).await;
11928        assert_openai_stream_error(&body, "stub stream chunk failed");
11929    }
11930
11931    #[tokio::test]
11932    async fn route_chat_stream_chunk_failure_writes_replay_diagnostics() {
11933        let root = unique_request_dump_dir("chat-stream-chunk-failure");
11934        let response = post_json(
11935            router_with_stream_chunk_failing_llm_and_request_dump_dir(root.clone()),
11936            "/v1/chat/completions",
11937            json!({
11938                "model": "failing-model",
11939                "messages": [{"role": "user", "content": "hello"}],
11940                "stream": true
11941            }),
11942        )
11943        .await;
11944        assert_eq!(response.status(), AxumStatusCode::OK);
11945        let body = response_text(response).await;
11946        assert_openai_stream_error(&body, "stub stream chunk failed");
11947        assert_chat_failure_replay_bundle(
11948            &root,
11949            "chat_completions_stream_next",
11950            "internal",
11951            "stub stream chunk failed",
11952        );
11953        let _ = fs::remove_dir_all(root);
11954    }
11955
11956    #[tokio::test]
11957    async fn route_completions_engine_unavailable_maps_to_503() {
11958        let response = post_json(
11959            router_without_llm(),
11960            "/v1/completions",
11961            json!({
11962                "model": "stub-model",
11963                "prompt": "complete me"
11964            }),
11965        )
11966        .await;
11967        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11968        let body = response_json(response).await;
11969        assert_eq!(body["error"]["type"], "service_unavailable_error");
11970        assert_eq!(body["error"]["param"], Value::Null);
11971    }
11972
11973    #[tokio::test]
11974    async fn route_embeddings_engine_unavailable_maps_to_503() {
11975        let response = post_json(
11976            router_without_llm(),
11977            "/v1/embeddings",
11978            json!({
11979                "model": "embed-model",
11980                "input": "hello"
11981            }),
11982        )
11983        .await;
11984        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11985        let body = response_json(response).await;
11986        assert_eq!(body["error"]["type"], "service_unavailable_error");
11987        assert_eq!(body["error"]["param"], Value::Null);
11988    }
11989
11990    #[tokio::test]
11991    async fn route_embeddings_contract_uses_stub_engine() {
11992        let response = post_json(
11993            router_with_stub_embed(),
11994            "/v1/embeddings",
11995            json!({
11996                "model": "stub-embed",
11997                "input": ["hi", "world"],
11998                "encoding_format": "float"
11999            }),
12000        )
12001        .await;
12002        assert_eq!(response.status(), AxumStatusCode::OK);
12003        let body = response_json(response).await;
12004        assert_eq!(body["object"], "list");
12005        assert_eq!(body["model"], "stub-embed");
12006        assert_eq!(body["usage"]["prompt_tokens"], 7);
12007        assert_eq!(body["usage"]["total_tokens"], 7);
12008
12009        let data = body["data"].as_array().expect("embedding data");
12010        assert_eq!(data.len(), 2, "body: {body}");
12011        assert_eq!(data[0]["object"], "embedding");
12012        assert_eq!(data[0]["index"], 0);
12013        assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
12014        assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
12015        assert_eq!(data[1]["index"], 1);
12016        assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
12017    }
12018
12019    #[tokio::test]
12020    async fn route_embeddings_public_alias_succeeds_and_unknown_alias_is_rejected() {
12021        let registry = ServedModelRegistry::try_new(
12022            "stub-embed",
12023            ServedModelKind::Embedding,
12024            vec!["public-embed".to_string()],
12025            vec![],
12026        )
12027        .unwrap();
12028        let server =
12029            AxumServer::from_embed(Arc::new(StubEmbed::new())).with_served_model_registry(registry);
12030        let accepted = post_json(
12031            server.build_router(),
12032            "/v1/embeddings",
12033            json!({"model": "public-embed", "input": "hello"}),
12034        )
12035        .await;
12036        assert_eq!(accepted.status(), AxumStatusCode::OK);
12037        assert_eq!(response_json(accepted).await["model"], "public-embed");
12038
12039        let rejected = post_json(
12040            server.build_router(),
12041            "/v1/embeddings",
12042            json!({"model": "stub-embed", "input": "hello"}),
12043        )
12044        .await;
12045        assert_eq!(rejected.status(), AxumStatusCode::BAD_REQUEST);
12046        let body = response_json(rejected).await;
12047        assert_eq!(body["error"]["type"], "invalid_request_error");
12048        assert_eq!(body["error"]["param"], "model");
12049    }
12050
12051    #[tokio::test]
12052    async fn route_embeddings_rejects_unsupported_encoding_format() {
12053        let response = post_json(
12054            router_with_stub_embed(),
12055            "/v1/embeddings",
12056            json!({
12057                "model": "stub-embed",
12058                "input": "hi",
12059                "encoding_format": "base64"
12060            }),
12061        )
12062        .await;
12063        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12064        let body = response_json(response).await;
12065        assert_eq!(body["error"]["type"], "invalid_request_error");
12066        assert_eq!(body["error"]["param"], "encoding_format");
12067    }
12068
12069    #[tokio::test]
12070    async fn route_embeddings_rejects_empty_input_with_field_param() {
12071        let response = post_json(
12072            router_with_stub_embed(),
12073            "/v1/embeddings",
12074            json!({
12075                "model": "stub-embed",
12076                "input": []
12077            }),
12078        )
12079        .await;
12080        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12081        let body = response_json(response).await;
12082        assert_eq!(body["error"]["type"], "invalid_request_error");
12083        assert_eq!(body["error"]["param"], "input");
12084    }
12085
12086    #[tokio::test]
12087    async fn route_embeddings_rejects_empty_item_with_field_param() {
12088        let response = post_json(
12089            router_with_stub_embed(),
12090            "/v1/embeddings",
12091            json!({
12092                "model": "stub-embed",
12093                "input": [{}]
12094            }),
12095        )
12096        .await;
12097        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12098        let body = response_json(response).await;
12099        assert_eq!(body["error"]["type"], "invalid_request_error");
12100        assert_eq!(body["error"]["param"], "input");
12101    }
12102
12103    #[tokio::test]
12104    async fn route_embeddings_invalid_json_maps_to_openai_error() {
12105        let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
12106        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12107        let body = response_json(response).await;
12108        assert_eq!(body["error"]["type"], "invalid_request_error");
12109        assert_eq!(body["error"]["param"], Value::Null);
12110        assert!(body["error"]["message"]
12111            .as_str()
12112            .unwrap()
12113            .contains("invalid embeddings request"));
12114    }
12115
12116    #[tokio::test]
12117    async fn route_transcriptions_engine_unavailable_maps_to_503() {
12118        let boundary = "ferrum-test-boundary";
12119        let body = concat!(
12120            "--ferrum-test-boundary\r\n",
12121            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12122            "Content-Type: audio/wav\r\n",
12123            "\r\n",
12124            "RIFFtest\r\n",
12125            "--ferrum-test-boundary--\r\n"
12126        );
12127        let response = post_multipart(
12128            router_without_llm(),
12129            "/v1/audio/transcriptions",
12130            boundary,
12131            body,
12132        )
12133        .await;
12134        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
12135        let body = response_json(response).await;
12136        assert_eq!(body["error"]["type"], "service_unavailable_error");
12137        assert_eq!(body["error"]["param"], Value::Null);
12138    }
12139
12140    #[tokio::test]
12141    async fn route_transcriptions_contract_uses_stub_engine() {
12142        let boundary = "ferrum-test-boundary";
12143        let body = concat!(
12144            "--ferrum-test-boundary\r\n",
12145            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12146            "Content-Type: audio/wav\r\n",
12147            "\r\n",
12148            "RIFFtest\r\n",
12149            "--ferrum-test-boundary\r\n",
12150            "Content-Disposition: form-data; name=\"language\"\r\n",
12151            "\r\n",
12152            "en\r\n",
12153            "--ferrum-test-boundary\r\n",
12154            "Content-Disposition: form-data; name=\"response_format\"\r\n",
12155            "\r\n",
12156            "json\r\n",
12157            "--ferrum-test-boundary--\r\n"
12158        );
12159        let response = post_multipart(
12160            router_with_stub_transcribe(),
12161            "/v1/audio/transcriptions",
12162            boundary,
12163            body,
12164        )
12165        .await;
12166        assert_eq!(response.status(), AxumStatusCode::OK);
12167        let body = response_json(response).await;
12168        assert_eq!(body["text"], "bytes:8:en");
12169    }
12170
12171    #[tokio::test]
12172    async fn route_transcriptions_rejects_unsupported_response_format() {
12173        let boundary = "ferrum-test-boundary";
12174        let body = concat!(
12175            "--ferrum-test-boundary\r\n",
12176            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12177            "Content-Type: audio/wav\r\n",
12178            "\r\n",
12179            "RIFFtest\r\n",
12180            "--ferrum-test-boundary\r\n",
12181            "Content-Disposition: form-data; name=\"response_format\"\r\n",
12182            "\r\n",
12183            "text\r\n",
12184            "--ferrum-test-boundary--\r\n"
12185        );
12186        let response = post_multipart(
12187            router_with_stub_transcribe(),
12188            "/v1/audio/transcriptions",
12189            boundary,
12190            body,
12191        )
12192        .await;
12193        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12194        let body = response_json(response).await;
12195        assert_eq!(body["error"]["type"], "invalid_request_error");
12196        assert_eq!(body["error"]["param"], "response_format");
12197    }
12198
12199    #[tokio::test]
12200    async fn route_transcriptions_rejects_missing_file_with_field_param() {
12201        let boundary = "ferrum-test-boundary";
12202        let body = concat!(
12203            "--ferrum-test-boundary\r\n",
12204            "Content-Disposition: form-data; name=\"language\"\r\n",
12205            "\r\n",
12206            "en\r\n",
12207            "--ferrum-test-boundary--\r\n"
12208        );
12209        let response = post_multipart(
12210            router_with_stub_transcribe(),
12211            "/v1/audio/transcriptions",
12212            boundary,
12213            body,
12214        )
12215        .await;
12216        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12217        let body = response_json(response).await;
12218        assert_eq!(body["error"]["type"], "invalid_request_error");
12219        assert_eq!(body["error"]["param"], "file");
12220    }
12221
12222    #[tokio::test]
12223    async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
12224        let response = post_json(
12225            router_with_stub_transcribe(),
12226            "/v1/audio/transcriptions",
12227            json!({}),
12228        )
12229        .await;
12230        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12231        let body = response_json(response).await;
12232        assert_eq!(body["error"]["type"], "invalid_request_error");
12233        assert_eq!(body["error"]["param"], Value::Null);
12234        assert!(body["error"]["message"]
12235            .as_str()
12236            .unwrap()
12237            .contains("invalid transcriptions request"));
12238    }
12239
12240    #[tokio::test]
12241    async fn route_speech_engine_unavailable_maps_to_503() {
12242        let response = post_json(
12243            router_without_llm(),
12244            "/v1/audio/speech",
12245            json!({
12246                "model": "tts-model",
12247                "input": "hello",
12248                "voice": "default"
12249            }),
12250        )
12251        .await;
12252        assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
12253        let body = response_json(response).await;
12254        assert_eq!(body["error"]["type"], "service_unavailable_error");
12255        assert_eq!(body["error"]["param"], Value::Null);
12256    }
12257
12258    #[tokio::test]
12259    async fn route_speech_contract_uses_stub_engine() {
12260        let response = post_json(
12261            router_with_stub_tts(),
12262            "/v1/audio/speech",
12263            json!({
12264                "model": "stub-tts",
12265                "input": "hello",
12266                "voice": "default",
12267                "response_format": "wav",
12268                "language": "english"
12269            }),
12270        )
12271        .await;
12272        assert_eq!(response.status(), AxumStatusCode::OK);
12273        assert_eq!(
12274            response.headers().get(header::CONTENT_TYPE).unwrap(),
12275            "audio/wav"
12276        );
12277        let body = response_bytes(response).await;
12278        assert!(body.len() > 44, "WAV should include header and PCM data");
12279        assert_eq!(&body[0..4], b"RIFF");
12280        assert_eq!(&body[8..12], b"WAVE");
12281    }
12282
12283    #[tokio::test]
12284    async fn route_speech_streaming_contract_uses_stub_engine() {
12285        let response = post_json(
12286            router_with_stub_tts(),
12287            "/v1/audio/speech",
12288            json!({
12289                "model": "stub-tts",
12290                "input": "hello",
12291                "voice": "default",
12292                "response_format": "wav",
12293                "stream": true
12294            }),
12295        )
12296        .await;
12297        assert_eq!(response.status(), AxumStatusCode::OK);
12298        assert_eq!(
12299            response.headers().get(header::CONTENT_TYPE).unwrap(),
12300            "audio/wav"
12301        );
12302        assert_eq!(
12303            response.headers().get(header::TRANSFER_ENCODING).unwrap(),
12304            "chunked"
12305        );
12306        let body = response_bytes(response).await;
12307        assert!(body.len() > 44, "streaming WAV should include audio bytes");
12308        assert_eq!(&body[0..4], b"RIFF");
12309        assert_eq!(&body[8..12], b"WAVE");
12310    }
12311
12312    #[tokio::test]
12313    async fn route_speech_pcm_response_format_returns_raw_pcm() {
12314        let response = post_json(
12315            router_with_stub_tts(),
12316            "/v1/audio/speech",
12317            json!({
12318                "model": "stub-tts",
12319                "input": "hello",
12320                "voice": "default",
12321                "response_format": "pcm"
12322            }),
12323        )
12324        .await;
12325        assert_eq!(response.status(), AxumStatusCode::OK);
12326        assert_eq!(
12327            response.headers().get(header::CONTENT_TYPE).unwrap(),
12328            "audio/pcm"
12329        );
12330        let body = response_bytes(response).await;
12331        assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
12332        assert_eq!(&body[0..2], &[0, 0]);
12333        assert_ne!(&body[0..4], b"RIFF");
12334    }
12335
12336    #[tokio::test]
12337    async fn route_speech_rejects_unsupported_response_format() {
12338        let response = post_json(
12339            router_with_stub_tts(),
12340            "/v1/audio/speech",
12341            json!({
12342                "model": "stub-tts",
12343                "input": "hello",
12344                "voice": "default",
12345                "response_format": "mp3"
12346            }),
12347        )
12348        .await;
12349        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12350        let body = response_json(response).await;
12351        assert_eq!(body["error"]["type"], "invalid_request_error");
12352        assert_eq!(body["error"]["param"], "response_format");
12353    }
12354
12355    #[tokio::test]
12356    async fn route_speech_invalid_json_maps_to_openai_error() {
12357        let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
12358        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12359        let body = response_json(response).await;
12360        assert_eq!(body["error"]["type"], "invalid_request_error");
12361        assert_eq!(body["error"]["param"], Value::Null);
12362        assert!(body["error"]["message"]
12363            .as_str()
12364            .unwrap()
12365            .contains("invalid speech request"));
12366    }
12367
12368    #[tokio::test]
12369    async fn route_completions_generation_failure_maps_to_500() {
12370        let response = post_json(
12371            router_with_failing_llm(),
12372            "/v1/completions",
12373            json!({
12374                "model": "failing-model",
12375                "prompt": "complete me"
12376            }),
12377        )
12378        .await;
12379        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
12380        let body = response_json(response).await;
12381        assert_eq!(body["error"]["type"], "internal_server_error");
12382        assert!(body["error"]["message"]
12383            .as_str()
12384            .unwrap()
12385            .contains("stub generation failed"));
12386    }
12387
12388    #[tokio::test]
12389    async fn route_completions_stream_start_failure_maps_to_500_before_sse() {
12390        let response = post_json(
12391            router_with_failing_llm(),
12392            "/v1/completions",
12393            json!({
12394                "model": "failing-model",
12395                "prompt": "complete me",
12396                "stream": true
12397            }),
12398        )
12399        .await;
12400        assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
12401        let body = response_json(response).await;
12402        assert_eq!(body["error"]["type"], "internal_server_error");
12403        assert!(body["error"]["message"]
12404            .as_str()
12405            .unwrap()
12406            .contains("stub stream failed"));
12407    }
12408
12409    #[tokio::test]
12410    async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
12411        let response = post_json(
12412            router_with_stream_chunk_failing_llm(),
12413            "/v1/completions",
12414            json!({
12415                "model": "failing-model",
12416                "prompt": "complete me",
12417                "stream": true
12418            }),
12419        )
12420        .await;
12421        assert_eq!(response.status(), AxumStatusCode::OK);
12422        let body = response_text(response).await;
12423        assert_openai_stream_error(&body, "stub stream chunk failed");
12424    }
12425
12426    #[tokio::test]
12427    async fn route_completions_contract_uses_stub_engine() {
12428        let response = post_json(
12429            router_with_stub("done"),
12430            "/v1/completions",
12431            json!({
12432                "model": "stub-model",
12433                "prompt": "complete me",
12434                "max_tokens": 8,
12435                "temperature": 0.0
12436            }),
12437        )
12438        .await;
12439        assert_eq!(response.status(), AxumStatusCode::OK);
12440        let body = response_json(response).await;
12441        assert_eq!(body["object"], "text_completion");
12442        assert_eq!(body["choices"][0]["text"], "done");
12443        assert_eq!(body["usage"]["prompt_tokens"], 7);
12444        assert_eq!(body["usage"]["completion_tokens"], 2);
12445    }
12446
12447    #[tokio::test]
12448    async fn route_completions_public_alias_maps_to_internal_model() {
12449        let engine = Arc::new(CapturingLlm::new());
12450        let registry = ServedModelRegistry::try_new(
12451            "qwen3",
12452            ServedModelKind::Llm,
12453            vec!["served-alias".to_string()],
12454            vec![],
12455        )
12456        .unwrap();
12457        let router = AxumServer::from_llm(engine.clone())
12458            .with_served_model_registry(registry)
12459            .build_router();
12460        let response = post_json(
12461            router,
12462            "/v1/completions",
12463            json!({"model": "served-alias", "prompt": "complete me"}),
12464        )
12465        .await;
12466
12467        assert_eq!(response.status(), AxumStatusCode::OK);
12468        assert_eq!(response_json(response).await["model"], "served-alias");
12469        assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
12470    }
12471
12472    #[tokio::test]
12473    async fn route_completions_streaming_contract_uses_stub_engine() {
12474        let response = post_json(
12475            router_with_stub("done"),
12476            "/v1/completions",
12477            json!({
12478                "model": "stub-model",
12479                "prompt": "complete me",
12480                "max_tokens": 8,
12481                "temperature": 0.0,
12482                "stream": true
12483            }),
12484        )
12485        .await;
12486        assert_eq!(response.status(), AxumStatusCode::OK);
12487        let body = response_text(response).await;
12488        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12489        assert!(
12490            body.contains("\"object\":\"text_completion\""),
12491            "missing completion chunk: {body}"
12492        );
12493        assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
12494        assert!(
12495            body.contains("\"choices\":[],\"usage\""),
12496            "missing separate usage chunk: {body}"
12497        );
12498        assert!(
12499            body.contains("\"prompt_tokens\":5"),
12500            "stream usage should come from engine token usage: {body}"
12501        );
12502        assert!(
12503            body.contains("\"completion_tokens\":1"),
12504            "stream completion usage should come from engine token usage: {body}"
12505        );
12506    }
12507
12508    #[tokio::test]
12509    async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
12510        let response = post_json(
12511            router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
12512            "/v1/completions",
12513            json!({
12514                "model": "stub-model",
12515                "prompt": "complete me",
12516                "max_tokens": 2,
12517                "temperature": 0.0,
12518                "stream": true
12519            }),
12520        )
12521        .await;
12522        assert_eq!(response.status(), AxumStatusCode::OK);
12523        let body = response_text(response).await;
12524        assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
12525        assert!(
12526            body.contains("\"text\":\"do\""),
12527            "missing first chunk: {body}"
12528        );
12529        assert!(
12530            body.contains("\"text\":\"ne\""),
12531            "missing second chunk: {body}"
12532        );
12533        assert!(
12534            body.contains("\"choices\":[],\"usage\""),
12535            "missing separate usage chunk from final engine chunk: {body}"
12536        );
12537        assert!(
12538            body.contains("\"prompt_tokens\":5"),
12539            "stream usage should come from engine final usage: {body}"
12540        );
12541    }
12542
12543    #[tokio::test]
12544    async fn route_completions_invalid_json_maps_to_openai_error() {
12545        let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
12546        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12547        let body = response_json(response).await;
12548        assert_eq!(body["error"]["type"], "invalid_request_error");
12549        assert_eq!(body["error"]["param"], Value::Null);
12550        assert!(body["error"]["message"]
12551            .as_str()
12552            .unwrap()
12553            .contains("invalid completions request"));
12554    }
12555
12556    #[tokio::test]
12557    async fn route_completions_rejects_unsupported_fields_explicitly() {
12558        for (extra, param) in [
12559            (json!({"n": 2}), "n"),
12560            (json!({"logprobs": 3}), "logprobs"),
12561            (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
12562        ] {
12563            let mut body = json!({
12564                "model": "stub-model",
12565                "prompt": "complete me"
12566            });
12567            body.as_object_mut()
12568                .expect("object")
12569                .extend(extra.as_object().expect("extra object").clone());
12570            let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
12571            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12572            let body = response_json(response).await;
12573            assert_eq!(body["error"]["type"], "invalid_request_error");
12574            assert_eq!(body["error"]["param"], param);
12575        }
12576    }
12577
12578    #[tokio::test]
12579    async fn streaming_completions_do_not_synthesize_whitespace_usage() {
12580        let response = post_json(
12581            router_with_stub_without_stream_usage("done"),
12582            "/v1/completions",
12583            json!({
12584                "model": "stub-model",
12585                "prompt": "one two three four",
12586                "stream": true
12587            }),
12588        )
12589        .await;
12590        assert_eq!(response.status(), AxumStatusCode::OK);
12591        let body = response_text(response).await;
12592        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12593        assert!(
12594            !body.contains("\"usage\":{\"prompt_tokens\""),
12595            "server must not synthesize whitespace-count completion usage: {body}"
12596        );
12597    }
12598
12599    #[tokio::test]
12600    async fn chat_rejects_n_not_one_with_openai_error_param() {
12601        let request = chat_request(json!({"n": 2}));
12602        let err = chat_completions_handler(
12603            State(state_with_stub("unused")),
12604            HeaderMap::new(),
12605            Ok(Json(request)),
12606        )
12607        .await
12608        .expect_err("n=2 should reject");
12609        let (status, body) = error_json(err).await;
12610        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12611        assert_eq!(body["error"]["type"], "invalid_request_error");
12612        assert_eq!(body["error"]["param"], "n");
12613    }
12614
12615    #[tokio::test]
12616    async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
12617        for (extra, param) in [
12618            (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
12619            (json!({"logprobs": true}), "logprobs"),
12620            (json!({"top_logprobs": 2}), "top_logprobs"),
12621        ] {
12622            let request = chat_request(extra);
12623            let err = chat_completions_handler(
12624                State(state_with_stub("unused")),
12625                HeaderMap::new(),
12626                Ok(Json(request)),
12627            )
12628            .await
12629            .expect_err("unsupported field should reject");
12630            let (status, body) = error_json(err).await;
12631            assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12632            assert_eq!(body["error"]["param"], param);
12633            assert_eq!(body["error"]["type"], "invalid_request_error");
12634        }
12635    }
12636
12637    #[tokio::test]
12638    async fn chat_stream_options_include_usage_controls_stream_usage() {
12639        let request = chat_request(json!({
12640            "stream": true,
12641            "stream_options": {"include_usage": true}
12642        }));
12643        let response = chat_completions_handler(
12644            State(state_with_stub("ok")),
12645            HeaderMap::new(),
12646            Ok(Json(request)),
12647        )
12648        .await
12649        .expect("stream response");
12650        assert_eq!(response.status(), AxumStatusCode::OK);
12651        let body = response_text(response).await;
12652        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12653        assert!(
12654            body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
12655            "include_usage=true should emit stream usage: {body}"
12656        );
12657        assert!(
12658            body.contains("\"choices\":[],\"usage\""),
12659            "include_usage=true should use a separate usage chunk: {body}"
12660        );
12661        assert!(
12662            body.contains("\"prompt_tokens\":5"),
12663            "stream usage should come from engine token usage: {body}"
12664        );
12665
12666        let request = chat_request(json!({"stream": true}));
12667        let response = chat_completions_handler(
12668            State(state_with_stub("ok")),
12669            HeaderMap::new(),
12670            Ok(Json(request)),
12671        )
12672        .await
12673        .expect("stream response");
12674        let body = response_text(response).await;
12675        assert!(
12676            !body.contains("\"usage\":{\"prompt_tokens\""),
12677            "stream usage should be omitted unless requested: {body}"
12678        );
12679    }
12680
12681    #[tokio::test]
12682    async fn streaming_chat_does_not_synthesize_whitespace_usage() {
12683        let response = post_json(
12684            router_with_stub_without_stream_usage("ok"),
12685            "/v1/chat/completions",
12686            json!({
12687                "model": "stub-model",
12688                "messages": [{"role": "user", "content": "one two three four"}],
12689                "stream": true,
12690                "stream_options": {"include_usage": true}
12691            }),
12692        )
12693        .await;
12694        assert_eq!(response.status(), AxumStatusCode::OK);
12695        let body = response_text(response).await;
12696        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12697        assert!(
12698            !body.contains("\"usage\":{\"prompt_tokens\""),
12699            "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
12700        );
12701    }
12702
12703    #[test]
12704    fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
12705        let request: ChatCompletionsRequest = serde_json::from_value(json!({
12706            "model": "qwen3",
12707            "messages": [
12708                {"role": "user", "content": "Use the weather tool."},
12709                {
12710                    "role": "assistant",
12711                    "content": null,
12712                    "tool_calls": [{
12713                        "id": "call_1",
12714                        "type": "function",
12715                        "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
12716                    }]
12717                },
12718                {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
12719            ],
12720            "tools": [{
12721                "type": "function",
12722                "function": {
12723                    "name": "weather",
12724                    "description": "Get weather",
12725                    "parameters": {
12726                        "type": "object",
12727                        "properties": {"city": {"type": "string"}},
12728                        "required": ["city"]
12729                    }
12730                }
12731            }],
12732            "tool_choice": "auto"
12733        }))
12734        .expect("tool request parses");
12735
12736        validate_chat_request(&request).expect("tool request validates");
12737        let internal = convert_chat_request(&request).expect("convert");
12738        assert!(internal.prompt.contains("\"tools\":[{"));
12739        assert!(internal.prompt.contains("\"type\":\"function\""));
12740        assert!(internal.prompt.contains("\"name\":\"weather\""));
12741        assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
12742        assert!(internal.prompt.contains("\"tool_calls\":[{"));
12743        assert!(internal.prompt.contains("\"id\":\"call_1\""));
12744        assert!(internal
12745            .prompt
12746            .contains("<|im_start|>tool\nsunny<|im_end|>"));
12747        assert_eq!(
12748            internal.metadata["openai_tools"][0]["function"]["name"],
12749            "weather"
12750        );
12751        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12752        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12753            panic!("expected structured chat api_request");
12754        };
12755        assert_eq!(api.messages.len(), 3);
12756        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
12757        assert_eq!(api.messages[2].content, "sunny");
12758        assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
12759        assert_eq!(api.tools[0].function.name, "weather");
12760        assert_eq!(
12761            api.tool_choice,
12762            Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12763        );
12764        assert_eq!(
12765            api.messages[1].tool_calls[0].function.arguments,
12766            "{\"city\":\"Paris\"}"
12767        );
12768    }
12769
12770    #[test]
12771    fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
12772        let request: ChatCompletionsRequest = serde_json::from_value(json!({
12773            "model": "served-alias",
12774            "messages": [{"role": "user", "content": "Use the weather tool."}],
12775            "tools": [{
12776                "type": "function",
12777                "function": {
12778                    "name": "weather",
12779                    "description": "Get weather",
12780                    "parameters": {
12781                        "type": "object",
12782                        "properties": {"city": {"type": "string"}},
12783                        "required": ["city"]
12784                    }
12785                }
12786            }]
12787        }))
12788        .expect("tool request parses");
12789
12790        validate_chat_request(&request).expect("tool request validates");
12791        let internal = convert_chat_request(&request).expect("convert");
12792        assert!(internal.prompt.contains("\"tools\":[{"));
12793        assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
12794        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12795        let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
12796            .as_array()
12797            .expect("initial forbidden token list");
12798        assert_eq!(initial_forbidden, &[serde_json::json!(THINK_END_TAG)]);
12799        assert_eq!(
12800            internal.sampling_params.response_format,
12801            ferrum_types::ResponseFormat::Text,
12802            "auto tool choice must preserve native model selection instead of forcing arguments JSON",
12803        );
12804        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12805            panic!("expected structured chat api_request");
12806        };
12807        assert_eq!(
12808            api.tool_choice,
12809            Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12810        );
12811    }
12812
12813    #[test]
12814    fn omitted_tool_choice_uses_native_template_protocol_without_hard_schema() {
12815        let request: ChatCompletionsRequest = serde_json::from_value(json!({
12816            "model": "served-alias",
12817            "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
12818            "tools": [{
12819                "type": "function",
12820                "function": {
12821                    "name": "get_weather",
12822                    "description": "查询指定城市的当前天气",
12823                    "parameters": {
12824                        "type": "object",
12825                        "properties": {
12826                            "city": {"type": "string"},
12827                            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12828                        },
12829                        "required": ["city"]
12830                    }
12831                }
12832            }]
12833        }))
12834        .expect("tool request parses");
12835        let template = ModelChatTemplate::new(
12836            "{% 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 %}",
12837            "function-parameter-xml-template",
12838        );
12839
12840        validate_chat_request(&request).expect("tool request validates");
12841        let internal =
12842            convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
12843                .expect("convert");
12844        assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12845        assert_eq!(
12846            internal.sampling_params.response_format,
12847            ferrum_types::ResponseFormat::Text,
12848        );
12849        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
12850            panic!("expected chat API request");
12851        };
12852        assert_eq!(
12853            api.tool_call_protocol,
12854            ferrum_types::ApiToolCallProtocol::FunctionParameterXml,
12855        );
12856    }
12857
12858    #[test]
12859    fn tool_schema_response_format_bounds_unconstrained_strings() {
12860        let request: ChatCompletionsRequest = serde_json::from_value(json!({
12861            "model": "served-alias",
12862            "messages": [{"role": "user", "content": "Use the selected tool."}],
12863            "tools": [{
12864                "type": "function",
12865                "function": {
12866                    "name": "get_weather",
12867                    "parameters": {
12868                        "type": "object",
12869                        "properties": {
12870                            "city": {"type": "string"},
12871                            "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12872                        },
12873                        "required": ["city"]
12874                    }
12875                }
12876            }],
12877            "tool_choice": {
12878                "type": "function",
12879                "function": {"name": "get_weather"}
12880            }
12881        }))
12882        .expect("tool request parses");
12883
12884        validate_chat_request(&request).expect("tool request validates");
12885        let internal = convert_chat_request(&request).expect("convert");
12886        match internal.sampling_params.response_format {
12887            ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
12888                let value: serde_json::Value =
12889                    serde_json::from_str(schema).expect("schema should be JSON");
12890                assert_eq!(
12891                    value["properties"]["city"]["maxLength"],
12892                    DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
12893                );
12894                assert_eq!(
12895                    value["properties"]["unit"]["enum"],
12896                    json!(["celsius", "fahrenheit"])
12897                );
12898                assert!(
12899                    value["properties"]["unit"]["maxLength"].is_null(),
12900                    "enum string should remain finite via enum instead of maxLength: {value}"
12901                );
12902            }
12903            ref other => panic!("expected forced tool json schema, got {other:?}"),
12904        }
12905    }
12906
12907    #[test]
12908    fn forced_native_tool_choice_preserves_xml_framing_with_a_tool_only_grammar() {
12909        let template = ModelChatTemplate::new(
12910            "{% 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 %}",
12911            "native-tool-fixture",
12912        );
12913        for choice in [
12914            json!({"type":"function","function":{"name":"calc"}}),
12915            json!("required"),
12916        ] {
12917            let request: ChatCompletionsRequest = serde_json::from_value(json!({
12918                "model":"served-alias","messages":[{"role":"user","content":"Use the selected tool."}],
12919                "tools":[
12920                    {"type":"function","function":{"name":"calc","parameters":{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}}},
12921                    {"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}
12922                ],
12923                "tool_choice":choice
12924            })).unwrap();
12925            validate_chat_request(&request).unwrap();
12926            let internal =
12927                convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
12928                    .unwrap();
12929            assert!(internal.requires_structured_output());
12930            assert_eq!(
12931                internal.sampling_params.response_format,
12932                ferrum_types::ResponseFormat::Text
12933            );
12934            assert!(internal.prompt.contains("<function=name>"));
12935            let Some(ferrum_types::ApiRequest::Chat(chat)) = &internal.api_request else {
12936                panic!("chat contract")
12937            };
12938            assert!(chat.requires_native_tool_call());
12939            assert!(chat.allows_tool_name("calc"));
12940            assert_eq!(chat.allows_tool_name("lookup"), choice == json!("required"));
12941            assert_eq!(
12942                internal.sampling_params.structured_output_start,
12943                StructuredOutputStart::Immediate
12944            );
12945        }
12946    }
12947
12948    #[test]
12949    fn harmony_named_tool_choice_preserves_native_protocol_envelope() {
12950        let request: ChatCompletionsRequest = serde_json::from_value(json!({
12951            "model": "gpt-oss-20b-mxfp4",
12952            "messages": [{
12953                "role": "user",
12954                "content": "Call get_weather exactly once with city set to Paris."
12955            }],
12956            "tools": [{
12957                "type": "function",
12958                "function": {
12959                    "name": "get_weather",
12960                    "parameters": {
12961                        "type": "object",
12962                        "properties": {"city": {"type": "string"}},
12963                        "required": ["city"],
12964                        "additionalProperties": false
12965                    }
12966                }
12967            }],
12968            "tool_choice": {
12969                "type": "function",
12970                "function": {"name": "get_weather"}
12971            }
12972        }))
12973        .expect("Harmony tool request parses");
12974        let mut template = ModelChatTemplate::new(
12975            "{% if tools %}<|start|>developer<|message|>{{ tools | tojson }}<|end|>{% endif %}{% for message in messages %}<|start|>{{ message.role }}<|message|>{{ message.content }}<|end|>{% endfor %}{% if add_generation_prompt %}<|start|>assistant{% endif %}",
12976            "harmony-tool-template",
12977        );
12978        template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
12979
12980        validate_chat_request(&request).expect("Harmony tool request validates");
12981        let internal =
12982            convert_chat_request_with_template_model(&request, "gpt-oss-20b", Some(&template))
12983                .expect("convert Harmony tool request");
12984
12985        assert!(internal.prompt.ends_with("<|start|>assistant"));
12986        assert_eq!(
12987            internal.sampling_params.model_output_protocol,
12988            ModelOutputProtocol::HarmonyGptOss
12989        );
12990        assert_eq!(
12991            internal.sampling_params.response_format,
12992            ferrum_types::ResponseFormat::Text,
12993            "Harmony must generate its channel/message/call envelope before tool arguments"
12994        );
12995        assert_eq!(
12996            internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY],
12997            json!([]),
12998            "Harmony declares no think delimiter and must not receive the generic structured-call mask"
12999        );
13000        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
13001            panic!("expected chat API request");
13002        };
13003        assert_eq!(
13004            api.tool_choice,
13005            Some(ferrum_types::ApiToolChoice::Function {
13006                tool_type: "function".to_string(),
13007                function: ferrum_types::ApiToolChoiceFunction {
13008                    name: "get_weather".to_string(),
13009                },
13010            })
13011        );
13012    }
13013
13014    #[test]
13015    fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
13016        let request: ChatCompletionsRequest = serde_json::from_value(json!({
13017            "model": "served-alias",
13018            "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
13019            "tools": [{
13020                "type": "function",
13021                "function": {
13022                    "name": "capture_quality_marker",
13023                    "description": "Record one marker.",
13024                    "parameters": {
13025                        "type": "object",
13026                        "properties": {
13027                            "marker": {"type": "string", "enum": ["ferrum0401"]},
13028                            "checksum": {"type": "string", "enum": ["S0004"]}
13029                        },
13030                        "required": ["marker", "checksum"]
13031                    }
13032                }
13033            }],
13034            "tool_choice": "required"
13035        }))
13036        .expect("tool request parses");
13037
13038        validate_chat_request(&request).expect("tool request validates");
13039        let internal = convert_chat_request(&request).expect("convert");
13040
13041        assert!(
13042            !internal.prompt.contains(
13043                "Output only a single JSON object containing the selected function arguments"
13044            ),
13045            "{}",
13046            internal.prompt
13047        );
13048        assert!(
13049            internal.prompt.contains("\"tool_choice\":\"required\""),
13050            "{}",
13051            internal.prompt
13052        );
13053        assert_eq!(internal.metadata["openai_tool_choice"], "required");
13054        match internal.sampling_params.response_format {
13055            ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
13056                assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
13057                assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
13058            }
13059            ref other => panic!("expected forced tool json schema, got {other:?}"),
13060        }
13061    }
13062
13063    #[test]
13064    fn required_tool_choice_suppresses_conflicting_response_format_instruction() {
13065        let request: ChatCompletionsRequest =
13066            serde_json::from_value(required_tool_with_strict_response_format_request(false))
13067                .expect("request parses");
13068
13069        validate_chat_request(&request).expect("request validates");
13070        let internal = convert_chat_request(&request).expect("convert");
13071
13072        assert!(
13073            !internal.prompt.contains("response_format requires"),
13074            "required tool output must not receive a conflicting content-schema instruction: {}",
13075            internal.prompt
13076        );
13077        let ferrum_types::ResponseFormat::JsonSchema(schema) =
13078            internal.sampling_params.response_format
13079        else {
13080            panic!("single required tool must use its argument schema");
13081        };
13082        let schema: Value = serde_json::from_str(&schema).expect("tool schema JSON");
13083        assert!(schema["properties"].get("city").is_some(), "{schema}");
13084        assert!(schema["properties"].get("answer").is_none(), "{schema}");
13085    }
13086
13087    #[test]
13088    fn required_multiple_tools_do_not_force_the_first_tool_schema() {
13089        let request: ChatCompletionsRequest = serde_json::from_value(json!({
13090            "model": "stub-model",
13091            "messages": [{"role": "user", "content": "Use the appropriate tool."}],
13092            "tools": [
13093                {
13094                    "type": "function",
13095                    "function": {
13096                        "name": "weather",
13097                        "parameters": {
13098                            "type": "object",
13099                            "properties": {"city": {"type": "string"}},
13100                            "required": ["city"]
13101                        }
13102                    }
13103                },
13104                {
13105                    "type": "function",
13106                    "function": {
13107                        "name": "calendar",
13108                        "parameters": {
13109                            "type": "object",
13110                            "properties": {"date": {"type": "string"}},
13111                            "required": ["date"]
13112                        }
13113                    }
13114                }
13115            ],
13116            "tool_choice": "required"
13117        }))
13118        .expect("request parses");
13119
13120        validate_chat_request(&request).expect("request validates");
13121        let internal = convert_chat_request(&request).expect("convert");
13122        assert_eq!(
13123            internal.sampling_params.response_format,
13124            ferrum_types::ResponseFormat::Text,
13125            "required permits either declared tool, so guided decoding cannot bind the first tool's arguments"
13126        );
13127    }
13128
13129    #[test]
13130    fn omitted_single_unrelated_tool_keeps_text_response_format() {
13131        let request: ChatCompletionsRequest = serde_json::from_value(json!({
13132            "model": "served-alias",
13133            "messages": [{"role": "user", "content": "讲一个短笑话。"}],
13134            "tools": [{
13135                "type": "function",
13136                "function": {
13137                    "name": "get_weather",
13138                    "description": "查询指定城市的当前天气",
13139                    "parameters": {
13140                        "type": "object",
13141                        "properties": {"city": {"type": "string"}},
13142                        "required": ["city"]
13143                    }
13144                }
13145            }]
13146        }))
13147        .expect("tool request parses");
13148
13149        validate_chat_request(&request).expect("tool request validates");
13150        let internal = convert_chat_request(&request).expect("convert");
13151        assert_eq!(
13152            internal.sampling_params.response_format,
13153            ferrum_types::ResponseFormat::Text
13154        );
13155    }
13156
13157    #[test]
13158    fn tool_choice_none_omits_tools_from_model_template_prompt() {
13159        let request: ChatCompletionsRequest = serde_json::from_value(json!({
13160            "model": "served-alias",
13161            "messages": [
13162                {"role": "user", "content": "Use the weather tool if needed."},
13163                {
13164                    "role": "assistant",
13165                    "content": null,
13166                    "tool_calls": [{
13167                        "id": "call_1",
13168                        "type": "function",
13169                        "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
13170                    }]
13171                },
13172                {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
13173            ],
13174            "tools": [{
13175                "type": "function",
13176                "function": {"name": "weather", "parameters": {"type": "object"}}
13177            }],
13178            "tool_choice": "none"
13179        }))
13180        .expect("tool_choice none request parses");
13181        let template = ModelChatTemplate::new(
13182            "{% 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 %}",
13183            "tool-choice-none-template",
13184        );
13185
13186        validate_chat_request(&request).expect("tool_choice none request validates");
13187        let internal = convert_chat_request_with_template_model(
13188            &request,
13189            "served-template-model",
13190            Some(&template),
13191        )
13192        .expect("convert");
13193        assert!(
13194            !internal.prompt.contains("<tools>"),
13195            "tool_choice none must not expose tools to the model template: {}",
13196            internal.prompt
13197        );
13198        assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
13199        assert_eq!(
13200            internal.metadata["openai_tools"][0]["function"]["name"],
13201            "weather"
13202        );
13203        assert_eq!(internal.metadata["openai_tool_choice"], "none");
13204        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13205            panic!("expected structured chat api_request");
13206        };
13207        assert_eq!(api.tools[0].function.name, "weather");
13208        assert_eq!(
13209            api.tool_choice,
13210            Some(ferrum_types::ApiToolChoice::Mode("none".into()))
13211        );
13212    }
13213
13214    #[test]
13215    fn specific_tool_choice_parses_into_structured_api_request() {
13216        let request: ChatCompletionsRequest = serde_json::from_value(json!({
13217            "model": "qwen3",
13218            "messages": [{"role": "user", "content": "Use the selected tool."}],
13219            "tools": [
13220                {
13221                    "type": "function",
13222                    "function": {"name": "weather", "parameters": {"type": "object"}}
13223                },
13224                {
13225                    "type": "function",
13226                    "function": {"name": "calendar", "parameters": {"type": "object"}}
13227                }
13228            ],
13229            "tool_choice": {
13230                "type": "function",
13231                "function": {"name": "weather"}
13232            }
13233        }))
13234        .expect("specific tool_choice request parses");
13235
13236        validate_chat_request(&request).expect("specific tool_choice validates");
13237        let internal = convert_chat_request(&request).expect("convert");
13238        assert!(internal.prompt.contains("\"tool_choice\":{"));
13239        assert!(internal.prompt.contains("\"name\":\"weather\""));
13240        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13241            panic!("expected structured chat api_request");
13242        };
13243        assert_eq!(
13244            api.tool_choice,
13245            Some(ferrum_types::ApiToolChoice::Function {
13246                tool_type: "function".to_string(),
13247                function: ferrum_types::ApiToolChoiceFunction {
13248                    name: "weather".to_string()
13249                },
13250            })
13251        );
13252
13253        let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
13254            "model": "qwen3",
13255            "messages": [{"role": "user", "content": "Use the selected tool."}],
13256            "tools": [{
13257                "type": "function",
13258                "function": {"name": "weather", "parameters": {"type": "object"}}
13259            }],
13260            "tool_choice": {
13261                "type": "function",
13262                "function": {"name": "calendar"}
13263            }
13264        }))
13265        .expect("invalid specific tool_choice request parses");
13266        let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
13267        match err {
13268            ServerError::InvalidRequest { param, .. } => {
13269                assert_eq!(param.as_deref(), Some("tool_choice"));
13270            }
13271            other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
13272        }
13273    }
13274
13275    #[test]
13276    fn legacy_function_role_messages_parse_into_structured_api_request() {
13277        let request: ChatCompletionsRequest = serde_json::from_value(json!({
13278            "model": "mystery-model",
13279            "messages": [
13280                {"role": "user", "content": "Call weather."},
13281                {
13282                    "role": "assistant",
13283                    "content": null,
13284                    "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
13285                },
13286                {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
13287            ],
13288            "functions": [{
13289                "name": "weather",
13290                "parameters": {
13291                    "type": "object",
13292                    "properties": {"city": {"type": "string"}},
13293                    "required": ["city"]
13294                }
13295            }],
13296            "function_call": "auto"
13297        }))
13298        .expect("legacy function request parses");
13299
13300        validate_chat_request(&request).expect("legacy function request validates");
13301        let internal = convert_chat_request(&request).expect("convert");
13302        assert!(
13303            internal
13304                .prompt
13305                .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
13306            "legacy function role should be preserved in fallback template: {}",
13307            internal.prompt
13308        );
13309        assert_eq!(
13310            internal.metadata["openai_legacy_functions"][0]["name"],
13311            "weather"
13312        );
13313        assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
13314        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13315            panic!("expected structured chat api_request");
13316        };
13317        assert_eq!(api.messages.len(), 3);
13318        assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
13319        assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
13320        assert_eq!(
13321            api.messages[1]
13322                .function_call
13323                .as_ref()
13324                .map(|call| call.name.as_str()),
13325            Some("weather")
13326        );
13327        assert_eq!(api.legacy_functions[0].name, "weather");
13328        assert_eq!(
13329            api.legacy_function_call,
13330            Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
13331        );
13332    }
13333
13334    #[test]
13335    fn specific_legacy_function_call_parses_into_structured_api_request() {
13336        let request: ChatCompletionsRequest = serde_json::from_value(json!({
13337            "model": "mystery-model",
13338            "messages": [{"role": "user", "content": "Use the selected function."}],
13339            "functions": [
13340                {"name": "weather", "parameters": {"type": "object"}},
13341                {"name": "calendar", "parameters": {"type": "object"}}
13342            ],
13343            "function_call": {"name": "weather"}
13344        }))
13345        .expect("specific function_call request parses");
13346
13347        validate_chat_request(&request).expect("specific function_call validates");
13348        let internal = convert_chat_request(&request).expect("convert");
13349        assert!(internal.prompt.contains("\"function_call\":{"));
13350        assert!(internal.prompt.contains("\"name\":\"weather\""));
13351        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13352            panic!("expected structured chat api_request");
13353        };
13354        assert_eq!(
13355            api.legacy_function_call,
13356            Some(ferrum_types::ApiFunctionCallChoice::Function {
13357                name: "weather".to_string(),
13358            })
13359        );
13360
13361        let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
13362            "model": "mystery-model",
13363            "messages": [{"role": "user", "content": "Use the selected function."}],
13364            "functions": [{"name": "weather", "parameters": {"type": "object"}}],
13365            "function_call": {"name": "calendar"}
13366        }))
13367        .expect("invalid specific function_call request parses");
13368        let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
13369        match err {
13370            ServerError::InvalidRequest { param, .. } => {
13371                assert_eq!(param.as_deref(), Some("function_call"));
13372            }
13373            other => panic!("expected invalid_request_error for function_call, got {other:?}"),
13374        }
13375    }
13376
13377    #[test]
13378    fn stream_text_delta_handles_unicode_boundaries() {
13379        let mut sent_len = 0usize;
13380        assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
13381        assert_eq!(sent_len, "你好".len());
13382        assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
13383        assert_eq!(sent_len, "你好世界".len());
13384    }
13385
13386    #[test]
13387    fn stream_text_delta_recovers_from_non_boundary_offset() {
13388        let mut sent_len = 1usize;
13389        assert_eq!(stream_text_delta("你好", &mut sent_len), "");
13390        assert_eq!(sent_len, "你好".len());
13391    }
13392
13393    #[test]
13394    fn assistant_tool_call_serializes_openai_shape() {
13395        let message = ChatMessage {
13396            role: MessageRole::Assistant,
13397            content: String::new(),
13398            reasoning: None,
13399            name: None,
13400            tool_calls: Some(vec![ChatToolCall {
13401                index: None,
13402                id: "call_1".to_string(),
13403                tool_type: "function".to_string(),
13404                function: ChatFunctionCall {
13405                    name: "weather".to_string(),
13406                    arguments: "{\"city\":\"Paris\"}".to_string(),
13407                },
13408            }]),
13409            tool_call_id: None,
13410            function_call: None,
13411        };
13412        let value = serde_json::to_value(message).expect("serialize");
13413        assert_eq!(value["role"], "assistant");
13414        assert_eq!(value["tool_calls"][0]["type"], "function");
13415        assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
13416    }
13417
13418    #[test]
13419    fn unsupported_multimodal_content_is_not_silently_dropped() {
13420        let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
13421            "model": "stub-model",
13422            "messages": [{
13423                "role": "user",
13424                "content": [
13425                    {"type": "text", "text": "describe this"},
13426                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
13427                ]
13428            }]
13429        }))
13430        .expect_err("unsupported content part should fail parsing");
13431        assert!(
13432            err.to_string()
13433                .contains("unsupported message content part type"),
13434            "unexpected error: {err}"
13435        );
13436    }
13437
13438    #[tokio::test]
13439    async fn completions_endpoint_uses_stub_engine() {
13440        let request = CompletionsRequest {
13441            model: "stub-model".to_string(),
13442            prompt: CompletionPrompt::Text("complete me".to_string()),
13443            max_tokens: Some(8),
13444            temperature: Some(0.0),
13445            top_p: None,
13446            n: None,
13447            stream: None,
13448            stop: None,
13449            logprobs: None,
13450            logit_bias: None,
13451        };
13452        let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
13453            .await
13454            .expect("completion response");
13455        assert_eq!(response.status(), AxumStatusCode::OK);
13456        let body = response_json(response).await;
13457        assert_eq!(body["object"], "text_completion");
13458        assert_eq!(body["choices"][0]["text"], "done");
13459        assert_eq!(body["usage"]["prompt_tokens"], 7);
13460        assert_eq!(body["usage"]["completion_tokens"], 2);
13461    }
13462
13463    #[tokio::test]
13464    async fn route_completions_rejects_non_string_prompt_with_field_param() {
13465        for prompt in [
13466            json!(["a", "b"]),
13467            json!({"text": "complete me"}),
13468            Value::Null,
13469        ] {
13470            let response = post_json(
13471                router_with_stub("unused"),
13472                "/v1/completions",
13473                json!({
13474                    "model": "stub-model",
13475                    "prompt": prompt
13476                }),
13477            )
13478            .await;
13479            assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13480            let body = response_json(response).await;
13481            assert_eq!(body["error"]["type"], "invalid_request_error");
13482            assert_eq!(body["error"]["param"], "prompt");
13483        }
13484
13485        let response = post_json(
13486            router_with_stub("unused"),
13487            "/v1/completions",
13488            json!({"model": "stub-model"}),
13489        )
13490        .await;
13491        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13492        let body = response_json(response).await;
13493        assert_eq!(body["error"]["type"], "invalid_request_error");
13494        assert_eq!(body["error"]["param"], "prompt");
13495    }
13496
13497    #[tokio::test]
13498    async fn stream_options_without_stream_is_invalid() {
13499        let request = chat_request(json!({"stream_options": {"include_usage": true}}));
13500        let err = chat_completions_handler(
13501            State(state_with_stub("unused")),
13502            HeaderMap::new(),
13503            Ok(Json(request)),
13504        )
13505        .await
13506        .expect_err("stream_options without stream should reject");
13507        let (status, body) = error_json(err).await;
13508        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13509        assert_eq!(body["error"]["param"], "stream_options");
13510        assert_eq!(body["error"]["type"], "invalid_request_error");
13511    }
13512
13513    #[tokio::test]
13514    async fn unknown_stream_option_is_rejected_instead_of_ignored() {
13515        let response = post_json(
13516            router_with_stub("unused"),
13517            "/v1/chat/completions",
13518            json!({
13519                "model": "stub-model",
13520                "messages": [{"role": "user", "content": "hello"}],
13521                "stream": true,
13522                "stream_options": {"continuous_usage_stats": true}
13523            }),
13524        )
13525        .await;
13526
13527        assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13528        let body = response_json(response).await;
13529        assert_eq!(body["error"]["type"], "invalid_request_error");
13530        assert!(
13531            body["error"]["message"]
13532                .as_str()
13533                .unwrap_or_default()
13534                .contains("invalid chat completions request"),
13535            "body: {body}"
13536        );
13537    }
13538
13539    #[tokio::test]
13540    async fn json_object_rejects_markdown_fence_instead_of_repairing() {
13541        let request = chat_request(json!({
13542            "response_format": {"type": "json_object"}
13543        }));
13544        let err = chat_completions_handler(
13545            State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
13546            HeaderMap::new(),
13547            Ok(Json(request)),
13548        )
13549        .await
13550        .expect_err("fenced json_object must fail");
13551        let (status, body) = error_json(err).await;
13552        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13553        assert_eq!(body["error"]["type"], "internal_server_error");
13554        assert!(body["error"]["message"]
13555            .as_str()
13556            .unwrap_or_default()
13557            .contains("response_format.json_object: invalid JSON"));
13558    }
13559
13560    #[tokio::test]
13561    async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
13562        let response = post_json(
13563            router_with_stub_stream_chunks(&[
13564                "<think>\n好的,我需要输出 JSON。",
13565                "\n</think>\n\n",
13566                "{\"name\":\"李四\",\"age\":30}",
13567            ]),
13568            "/v1/chat/completions",
13569            json!({
13570                "model": "stub-model",
13571                "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
13572                "stream": true,
13573                "response_format": {"type": "json_object"}
13574            }),
13575        )
13576        .await;
13577        assert_eq!(response.status(), AxumStatusCode::OK);
13578        let body = response_text(response).await;
13579        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
13580        assert!(
13581            body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
13582            "stream should emit clean JSON content: {body}"
13583        );
13584        assert!(
13585            body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
13586            "stream should keep thinking in reasoning field: {body}"
13587        );
13588        assert!(
13589            !body.contains(r#""content":"<think"#)
13590                && !body.contains(r#""content":"好的"#)
13591                && !body.contains(r#""content":"我需要"#),
13592            "thinking text must not leak as streamed content: {body}"
13593        );
13594    }
13595
13596    fn prompt_opened_literal_json_template() -> ModelChatTemplate {
13597        let template = ModelChatTemplate::new(
13598            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>{% endif %}",
13599            "prompt-opened-text-test",
13600        );
13601        assert_eq!(template.output_protocol, ModelOutputProtocol::Text);
13602        assert_eq!(
13603            template.reasoning_protocol,
13604            ModelReasoningProtocol::PromptOpened
13605        );
13606        let request = chat_request(json!({"response_format": {"type": "json_object"}}));
13607        let internal =
13608            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13609                .expect("convert prompt-opened Text request");
13610        assert!(internal.prompt.ends_with("<think>"));
13611        template
13612    }
13613
13614    #[tokio::test]
13615    async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sync() {
13616        let response = post_json(
13617            router_with_stub_and_template(
13618                "reason</think>\n{\"text\":\"<think>literal</think>\"}",
13619                prompt_opened_literal_json_template(),
13620            ),
13621            "/v1/chat/completions",
13622            json!({
13623                "model": "stub-model",
13624                "messages": [{"role": "user", "content": "Return a JSON object."}],
13625                "response_format": {"type": "json_object"}
13626            }),
13627        )
13628        .await;
13629        let status = response.status();
13630        let body = response_json(response).await;
13631        assert_eq!(status, AxumStatusCode::OK, "{body}");
13632        assert!(body.get("error").is_none(), "{body}");
13633        let message = &body["choices"][0]["message"];
13634        assert_eq!(message["content"], r#"{"text":"<think>literal</think>"}"#);
13635        assert_eq!(message["reasoning"], "reason");
13636        assert_eq!(body["choices"][0]["finish_reason"], "stop");
13637    }
13638
13639    #[tokio::test]
13640    async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sse() {
13641        for chunks in [
13642            vec!["reason</think>\n{\"text\":\"<think>literal</think>\"}"],
13643            vec![
13644                "reason</thi",
13645                "nk>\n{\"text\":\"<thi",
13646                "nk>literal</thi",
13647                "nk>\"}",
13648            ],
13649        ] {
13650            let router = AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(&chunks)))
13651                .with_prompt_template(Some(prompt_opened_literal_json_template()))
13652                .build_router();
13653            let response = post_json(
13654                router,
13655                "/v1/chat/completions",
13656                json!({
13657                    "model": "stub-model",
13658                    "messages": [{"role": "user", "content": "Return a JSON object."}],
13659                    "stream": true,
13660                    "stream_options": {"include_usage": true},
13661                    "response_format": {"type": "json_object"}
13662                }),
13663            )
13664            .await;
13665            let status = response.status();
13666            let body = response_text(response).await;
13667            assert_eq!(status, AxumStatusCode::OK, "{body}");
13668            let normalized = body.replace("\r\n", "\n");
13669            assert_eq!(normalized.matches("data: [DONE]").count(), 1, "{body}");
13670            assert!(normalized.ends_with("data: [DONE]\n\n"), "{body}");
13671            let events = responses_sse_json_events(&body);
13672            assert!(
13673                events.iter().all(|event| event.get("error").is_none()),
13674                "{body}"
13675            );
13676            let content: String = events
13677                .iter()
13678                .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
13679                .collect();
13680            let reasoning: String = events
13681                .iter()
13682                .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
13683                .collect();
13684            assert_eq!(content, r#"{"text":"<think>literal</think>"}"#);
13685            assert_eq!(reasoning, "reason");
13686            assert_eq!(
13687                serde_json::from_str::<Value>(&content).expect("intact JSON body"),
13688                json!({"text": "<think>literal</think>"})
13689            );
13690            let terminals: Vec<_> = events
13691                .iter()
13692                .enumerate()
13693                .filter(|(_, event)| !event["choices"][0]["finish_reason"].is_null())
13694                .collect();
13695            assert_eq!(terminals.len(), 1, "{body}");
13696            let (terminal_index, terminal) = terminals[0];
13697            assert_eq!(terminal["choices"][0]["finish_reason"], "stop");
13698            for event in &events[terminal_index..] {
13699                for field in ["content", "reasoning", "reasoning_content"] {
13700                    assert!(
13701                        event["choices"][0]["delta"][field]
13702                            .as_str()
13703                            .unwrap_or_default()
13704                            .is_empty(),
13705                        "payload after terminal: {event}"
13706                    );
13707                }
13708            }
13709            let usages: Vec<_> = events
13710                .iter()
13711                .enumerate()
13712                .filter(|(_, event)| !event["usage"].is_null())
13713                .collect();
13714            assert_eq!(usages.len(), 1, "{body}");
13715            let (usage_index, usage) = usages[0];
13716            assert!(terminal_index < usage_index, "{body}");
13717            assert_eq!(usage_index, events.len() - 1, "usage must be last: {body}");
13718            assert_eq!(usage["choices"], json!([]));
13719        }
13720    }
13721
13722    #[tokio::test]
13723    async fn json_object_rejects_non_json_model_output() {
13724        let request = chat_request(json!({
13725            "response_format": {"type": "json_object"}
13726        }));
13727        let err = chat_completions_handler(
13728            State(state_with_stub("not json")),
13729            HeaderMap::new(),
13730            Ok(Json(request)),
13731        )
13732        .await
13733        .expect_err("invalid json_object must fail");
13734        let (status, body) = error_json(err).await;
13735        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13736        assert_eq!(body["error"]["type"], "internal_server_error");
13737        assert!(body["error"]["message"]
13738            .as_str()
13739            .unwrap_or_default()
13740            .contains("response_format.json_object"));
13741    }
13742
13743    #[test]
13744    fn one_of_strict_json_schema_reaches_hard_decoder() {
13745        let request = chat_request(json!({
13746            "response_format": {
13747                "type": "json_schema",
13748                "json_schema": {
13749                    "name": "unsupported",
13750                    "strict": true,
13751                    "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13752                }
13753            }
13754        }));
13755        validate_chat_request(&request).expect("oneOf strict schema should validate");
13756        let internal = convert_chat_request(&request).expect("convert oneOf strict schema");
13757        let ferrum_types::ResponseFormat::JsonSchema(schema) =
13758            internal.sampling_params.response_format
13759        else {
13760            panic!("strict schema did not reach hard decoder");
13761        };
13762        assert_eq!(
13763            serde_json::from_str::<serde_json::Value>(&schema).unwrap()["oneOf"],
13764            json!([{"type": "string"}, {"type": "integer"}])
13765        );
13766        let schema = serde_json::from_str::<serde_json::Value>(&schema).unwrap();
13767        validate_json_text_against_schema(&schema, r#""answer""#)
13768            .expect("oneOf string branch should pass final validation");
13769        validate_json_text_against_schema(&schema, "7")
13770            .expect("oneOf integer branch should pass final validation");
13771        assert!(validate_json_text_against_schema(&schema, "true").is_err());
13772    }
13773
13774    #[tokio::test]
13775    async fn missing_json_schema_schema_rejects_with_field_param() {
13776        let request = chat_request(json!({
13777            "response_format": {
13778                "type": "json_schema",
13779                "json_schema": {
13780                    "name": "missing_schema",
13781                    "strict": true
13782                }
13783            }
13784        }));
13785        let err = chat_completions_handler(
13786            State(state_with_stub("unused")),
13787            HeaderMap::new(),
13788            Ok(Json(request)),
13789        )
13790        .await
13791        .expect_err("missing strict schema should reject");
13792        let (status, body) = error_json(err).await;
13793        assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13794        assert_eq!(body["error"]["param"], "response_format.json_schema");
13795        assert_eq!(body["error"]["type"], "invalid_request_error");
13796        assert!(body["error"]["message"]
13797            .as_str()
13798            .unwrap()
13799            .contains("schema is required"));
13800    }
13801
13802    #[test]
13803    fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
13804        let request = chat_request(json!({
13805            "response_format": {
13806                "type": "json_schema",
13807                "json_schema": {
13808                    "name": "best_effort",
13809                    "strict": false,
13810                    "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13811                }
13812            }
13813        }));
13814
13815        validate_chat_request(&request).expect("non-strict schema should not boundary reject");
13816        let internal = convert_chat_request(&request).expect("convert non-strict schema");
13817        assert!(
13818            internal
13819                .prompt
13820                .contains("response_format requires a single valid JSON value"),
13821            "response_format instruction should reach the model prompt: {}",
13822            internal.prompt
13823        );
13824        assert!(
13825            internal.prompt.contains("\"oneOf\""),
13826            "schema should reach the model prompt: {}",
13827            internal.prompt
13828        );
13829        assert_eq!(
13830            internal.sampling_params.response_format,
13831            ferrum_types::ResponseFormat::Text,
13832            "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
13833        );
13834        let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13835            panic!("expected structured chat api_request");
13836        };
13837        assert_eq!(
13838            api.response_format
13839                .as_ref()
13840                .and_then(|format| format.json_schema.as_ref())
13841                .and_then(|schema| schema.strict),
13842            Some(false)
13843        );
13844    }
13845
13846    #[test]
13847    fn json_object_response_format_instruction_reaches_model_prompt() {
13848        let request = chat_request(json!({
13849            "response_format": {"type": "json_object"}
13850        }));
13851
13852        let internal = convert_chat_request(&request).expect("convert json_object");
13853        assert!(
13854            internal
13855                .prompt
13856                .contains("response_format requires a single valid JSON object"),
13857            "response_format instruction should reach the model prompt: {}",
13858            internal.prompt
13859        );
13860        assert!(
13861            internal.prompt.contains("Output only JSON"),
13862            "JSON-only instruction should reach the model prompt: {}",
13863            internal.prompt
13864        );
13865        assert_eq!(
13866            internal.sampling_params.response_format,
13867            ferrum_types::ResponseFormat::JsonObject,
13868            "json_object must reach the tokenizer-aware hard decoder"
13869        );
13870        assert_eq!(
13871            internal.sampling_params.structured_output_start,
13872            StructuredOutputStart::Immediate
13873        );
13874    }
13875
13876    fn harmony_json_template() -> ModelChatTemplate {
13877        let mut template = ModelChatTemplate::new(
13878            "{% for message in messages %}<|start|>{{ message.role }}<|message|>{{ message.content }}<|end|>{% endfor %}{% if add_generation_prompt %}<|start|>assistant{% endif %}",
13879            "harmony-structured-template",
13880        );
13881        template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
13882        template
13883    }
13884
13885    #[test]
13886    fn harmony_structured_format_activates_at_final_payload() {
13887        let template = harmony_json_template();
13888        for (response_format, constrained) in [
13889            (json!({"type": "json_object"}), true),
13890            (
13891                json!({
13892                    "type": "json_schema",
13893                    "json_schema": {
13894                        "name": "answer",
13895                        "strict": true,
13896                        "schema": {
13897                            "type": "object",
13898                            "properties": {"answer": {"type": "integer"}},
13899                            "required": ["answer"],
13900                            "additionalProperties": false
13901                        }
13902                    }
13903                }),
13904                true,
13905            ),
13906            (
13907                json!({
13908                    "type": "json_schema",
13909                    "json_schema": {
13910                        "name": "best_effort",
13911                        "strict": false,
13912                        "schema": {"type": "object"}
13913                    }
13914                }),
13915                false,
13916            ),
13917            (json!({"type": "text"}), false),
13918        ] {
13919            let request = chat_request(json!({"response_format": response_format}));
13920            let internal =
13921                convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13922                    .unwrap();
13923            assert_eq!(
13924                internal.sampling_params.structured_output_start,
13925                if constrained {
13926                    StructuredOutputStart::HarmonyFinal
13927                } else {
13928                    StructuredOutputStart::Immediate
13929                }
13930            );
13931            assert_eq!(
13932                internal.sampling_params.response_completion_boundary,
13933                ResponseCompletionBoundary::Immediate,
13934                "Harmony framing must not be gated on a Text reasoning delimiter"
13935            );
13936            internal.sampling_params.validate().unwrap();
13937        }
13938    }
13939
13940    fn harmony_json_request(stream: bool) -> Value {
13941        json!({
13942            "model": "stub-model",
13943            "messages": [{"role": "user", "content": "Return an answer object."}],
13944            "stream": stream,
13945            "response_format": {
13946                "type": "json_schema",
13947                "json_schema": {
13948                    "name": "answer",
13949                    "strict": true,
13950                    "schema": {
13951                        "type": "object",
13952                        "properties": {"answer": {"type": "integer"}},
13953                        "required": ["answer"],
13954                        "additionalProperties": false
13955                    }
13956                }
13957            }
13958        })
13959    }
13960
13961    #[tokio::test]
13962    async fn harmony_strict_json_routes_validate_final_payload_in_sync_and_sse() {
13963        for (chunks, finish_reason, reasoning) in [
13964            (
13965                vec![
13966                    "<|channel|>fi",
13967                    "nal<|message|>{\"answer\":",
13968                    "42}<|return|>",
13969                ],
13970                FinishReason::EOS,
13971                "",
13972            ),
13973            (
13974                vec![
13975                    "<|channel|>analysis<|message|>Compute.",
13976                    "<|end|><|start|>assistant<|channel|>fi",
13977                    "nal<|message|>{\"answer\":42}<|return|>",
13978                ],
13979                FinishReason::EOS,
13980                "Compute.",
13981            ),
13982            (
13983                vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
13984                FinishReason::Length,
13985                "",
13986            ),
13987            (
13988                vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
13989                FinishReason::Stop,
13990                "",
13991            ),
13992        ] {
13993            for stream in [false, true] {
13994                let engine = StubLlm {
13995                    finish_reason,
13996                    ..StubLlm::with_stream_chunks(&chunks)
13997                };
13998                let router = AxumServer::from_llm(Arc::new(engine))
13999                    .with_prompt_template(Some(harmony_json_template()))
14000                    .build_router();
14001                let mut request = harmony_json_request(stream);
14002                if finish_reason == FinishReason::Stop {
14003                    // An explicit stop at the model's terminal removes that
14004                    // marker while preserving the already complete JSON value.
14005                    request["stop"] = json!(["<|return|>"]);
14006                }
14007                let response = post_json(router, "/v1/chat/completions", request).await;
14008                assert_eq!(response.status(), AxumStatusCode::OK);
14009                if stream {
14010                    let body = response_text(response).await;
14011                    assert!(body.contains("data: [DONE]"));
14012                    let events = responses_sse_json_events(&body);
14013                    assert!(events.iter().all(|event| event.get("error").is_none()));
14014                    let content: String = events
14015                        .iter()
14016                        .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
14017                        .collect();
14018                    let actual_reasoning: String = events
14019                        .iter()
14020                        .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
14021                        .collect();
14022                    assert_eq!(
14023                        serde_json::from_str::<Value>(&content).unwrap(),
14024                        json!({"answer": 42})
14025                    );
14026                    assert_eq!(actual_reasoning, reasoning);
14027                } else {
14028                    let body = response_json(response).await;
14029                    let message = &body["choices"][0]["message"];
14030                    assert_eq!(message["content"], "{\"answer\":42}");
14031                    assert_eq!(message["reasoning"].as_str().unwrap_or(""), reasoning);
14032                }
14033            }
14034        }
14035    }
14036
14037    #[tokio::test]
14038    async fn harmony_strict_json_routes_reject_bad_framing_and_payload_without_sse_leaks() {
14039        for output in [
14040            "{\"answer\":42}",
14041            "<|channel|>final<|message|>{\"answer\":42}",
14042            "<|channel|>final<|message|>{\"answer\":42}<|call|>",
14043            "<|channel|>analysis<|message|>Compute.<|end|>\
14044             <|start|>assistant<|channel|>final<|message|>{\"answer\":\"wrong\"}<|return|>",
14045        ] {
14046            for stream in [false, true] {
14047                let response = post_json(
14048                    router_with_stub_and_template(output, harmony_json_template()),
14049                    "/v1/chat/completions",
14050                    harmony_json_request(stream),
14051                )
14052                .await;
14053                if stream {
14054                    assert_eq!(response.status(), AxumStatusCode::OK);
14055                    let body = response_text(response).await;
14056                    assert!(body.contains("data: [DONE]"));
14057                    let events = responses_sse_json_events(&body);
14058                    assert!(events.iter().any(|event| event.get("error").is_some()));
14059                    for event in events {
14060                        for field in ["content", "reasoning"] {
14061                            assert!(event["choices"][0]["delta"][field]
14062                                .as_str()
14063                                .unwrap_or("")
14064                                .is_empty());
14065                        }
14066                    }
14067                } else {
14068                    assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
14069                    let body = response_json(response).await;
14070                    assert_eq!(body["error"]["type"], "internal_server_error");
14071                    assert!(body.get("choices").is_none());
14072                }
14073            }
14074        }
14075    }
14076
14077    #[test]
14078    fn json_object_thinking_template_activates_after_typed_end_delimiter() {
14079        let request = chat_request(json!({
14080            "response_format": {"type": "json_object"}
14081        }));
14082        let template = ModelChatTemplate::new(
14083            "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14084            "thinking-test-template",
14085        );
14086
14087        assert_eq!(
14088            template.reasoning_protocol,
14089            ModelReasoningProtocol::PromptOpened
14090        );
14091        let internal =
14092            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14093                .expect("convert thinking json_object");
14094
14095        assert!(internal.prompt.ends_with("<assistant><think>\n"));
14096        assert!(internal
14097            .prompt
14098            .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
14099        assert!(
14100            !internal.prompt.contains(THINK_END_TAG),
14101            "the instruction must not echo the typed end delimiter: {}",
14102            internal.prompt
14103        );
14104        assert_eq!(
14105            internal.sampling_params.structured_output_start,
14106            StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14107        );
14108        assert_eq!(
14109            internal.sampling_params.response_completion_boundary,
14110            ResponseCompletionBoundary::AfterDelimiterAndPayload {
14111                delimiter: THINK_END_TAG.to_string(),
14112                alternate_envelope: None,
14113            }
14114        );
14115    }
14116
14117    #[test]
14118    fn json_object_model_generated_thinking_activates_after_typed_end_delimiter() {
14119        let request = chat_request(json!({
14120            "response_format": {"type": "json_object"},
14121            "chat_template_kwargs": {"enable_thinking": true}
14122        }));
14123        let template = ModelChatTemplate::new(
14124            "{% 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 %}",
14125            "qwen3-model-generated-thinking-template",
14126        );
14127
14128        let internal =
14129            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14130                .expect("convert model-generated thinking json_object");
14131
14132        assert!(!has_unclosed_thinking_block(&internal.prompt));
14133        assert!(internal.prompt.ends_with("<assistant>"));
14134        assert!(internal
14135            .prompt
14136            .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
14137        assert!(
14138            !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
14139            "the instruction must not teach the model the typed reasoning delimiter: {}",
14140            internal.prompt
14141        );
14142        assert_eq!(
14143            internal.sampling_params.structured_output_start,
14144            StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14145        );
14146        assert_eq!(
14147            internal.sampling_params.response_completion_boundary,
14148            ResponseCompletionBoundary::AfterDelimiterAndPayload {
14149                delimiter: THINK_END_TAG.to_string(),
14150                alternate_envelope: None,
14151            }
14152        );
14153    }
14154
14155    #[test]
14156    fn strict_schema_model_generated_thinking_does_not_echo_typed_delimiter() {
14157        let request = chat_request(json!({
14158            "response_format": {
14159                "type": "json_schema",
14160                "json_schema": {
14161                    "name": "reasoning_result",
14162                    "strict": true,
14163                    "schema": {
14164                        "type": "object",
14165                        "properties": {
14166                            "result": {"type": "string", "const": "G00-c21-schema-OK"}
14167                        },
14168                        "required": ["result"],
14169                        "additionalProperties": false
14170                    }
14171                }
14172            },
14173            "chat_template_kwargs": {"enable_thinking": true}
14174        }));
14175        let template = ModelChatTemplate::new(
14176            "{% 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 %}",
14177            "qwen3-model-generated-thinking-template",
14178        );
14179
14180        let internal =
14181            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14182                .expect("convert model-generated thinking strict schema");
14183
14184        assert!(!has_unclosed_thinking_block(&internal.prompt));
14185        assert!(internal.prompt.ends_with("<assistant>"));
14186        assert!(internal.prompt.contains("G00-c21-schema-OK"));
14187        assert!(
14188            !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
14189            "the instruction must not teach the model the typed reasoning delimiter: {}",
14190            internal.prompt
14191        );
14192        assert_eq!(
14193            internal.sampling_params.structured_output_start,
14194            StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14195        );
14196        assert_eq!(
14197            internal.sampling_params.response_completion_boundary,
14198            ResponseCompletionBoundary::AfterDelimiterAndPayload {
14199                delimiter: THINK_END_TAG.to_string(),
14200                alternate_envelope: None,
14201            }
14202        );
14203    }
14204
14205    #[test]
14206    fn json_object_model_generated_thinking_hard_off_starts_immediately() {
14207        let request = chat_request(json!({
14208            "response_format": {"type": "json_object"},
14209            "chat_template_kwargs": {"enable_thinking": false}
14210        }));
14211        let template = ModelChatTemplate::new(
14212            "{% 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 %}",
14213            "qwen3-model-generated-thinking-template",
14214        );
14215
14216        let internal =
14217            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14218                .expect("convert disabled model-generated thinking json_object");
14219
14220        assert_eq!(
14221            internal.sampling_params.structured_output_start,
14222            StructuredOutputStart::Immediate
14223        );
14224        assert_eq!(
14225            internal.sampling_params.response_completion_boundary,
14226            ResponseCompletionBoundary::Immediate
14227        );
14228        assert!(internal.prompt.contains("no chain-of-thought"));
14229    }
14230
14231    #[test]
14232    fn response_completion_contract_is_set_on_text_thinking_template() {
14233        let request = chat_request(json!({}));
14234        let template = ModelChatTemplate::new(
14235            "{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14236            "thinking-test-template",
14237        );
14238
14239        let internal =
14240            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14241                .expect("convert thinking text request");
14242
14243        assert_eq!(
14244            internal.sampling_params.structured_output_start,
14245            StructuredOutputStart::Immediate
14246        );
14247        assert_eq!(
14248            internal.sampling_params.response_completion_boundary,
14249            ResponseCompletionBoundary::AfterDelimiterAndPayload {
14250                delimiter: THINK_END_TAG.to_string(),
14251                alternate_envelope: None,
14252            }
14253        );
14254    }
14255
14256    #[test]
14257    fn thinking_tool_request_compiles_typed_envelope_into_completion_contract() {
14258        let request = chat_request(json!({
14259            "tools": [{
14260                "type": "function",
14261                "function": {
14262                    "name": "weather",
14263                    "parameters": {
14264                        "type": "object",
14265                        "properties": {"city": {"type": "string"}},
14266                        "required": ["city"]
14267                    }
14268                }
14269            }]
14270        }));
14271        let template = ModelChatTemplate::new(
14272            "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14273            "thinking-tool-template",
14274        );
14275
14276        let internal =
14277            convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14278                .expect("convert thinking tool request");
14279
14280        assert_eq!(
14281            internal.sampling_params.response_completion_boundary,
14282            ResponseCompletionBoundary::AfterDelimiterAndPayload {
14283                delimiter: THINK_END_TAG.to_string(),
14284                alternate_envelope: Some(ferrum_types::ResponseCompletionEnvelope {
14285                    open_token_text: "<tool_call>".to_string(),
14286                    close_token_text: "</tool_call>".to_string(),
14287                    max_envelopes: 32,
14288                }),
14289            }
14290        );
14291    }
14292
14293    #[test]
14294    fn strict_json_schema_response_format_uses_guided_sampling_mode() {
14295        let request = chat_request(json!({
14296            "response_format": {
14297                "type": "json_schema",
14298                "json_schema": {
14299                    "name": "answer",
14300                    "strict": true,
14301                    "schema": {
14302                        "type": "object",
14303                        "properties": {"answer": {"type": "string"}},
14304                        "required": ["answer"]
14305                    }
14306                }
14307            }
14308        }));
14309
14310        let internal = convert_chat_request(&request).expect("convert strict json_schema");
14311        assert!(
14312            internal
14313                .prompt
14314                .contains("response_format requires a single valid JSON value"),
14315            "response_format instruction should reach the model prompt: {}",
14316            internal.prompt
14317        );
14318        let ferrum_types::ResponseFormat::JsonSchema(schema) =
14319            internal.sampling_params.response_format
14320        else {
14321            panic!(
14322                "strict json_schema must reach guided decoding, got {:?}",
14323                internal.sampling_params.response_format
14324            );
14325        };
14326        let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
14327        assert_eq!(schema["type"], "object");
14328        assert_eq!(schema["properties"]["answer"]["type"], "string");
14329        assert_eq!(schema["required"], json!(["answer"]));
14330    }
14331
14332    #[tokio::test]
14333    async fn strict_json_schema_validates_non_streaming_response() {
14334        let request = chat_request(json!({
14335            "response_format": {
14336                "type": "json_schema",
14337                "json_schema": {
14338                    "name": "answer",
14339                    "strict": true,
14340                    "schema": {
14341                        "type": "object",
14342                        "properties": {"answer": {"type": "string"}},
14343                        "required": ["answer"]
14344                    }
14345                }
14346            }
14347        }));
14348        let response = chat_completions_handler(
14349            State(state_with_stub("{\"answer\":\"yes\"}")),
14350            HeaderMap::new(),
14351            Ok(Json(request)),
14352        )
14353        .await
14354        .expect("strict response");
14355        assert_eq!(response.status(), AxumStatusCode::OK);
14356        let body = response_json(response).await;
14357        assert_eq!(
14358            body["choices"][0]["message"]["content"],
14359            "{\"answer\":\"yes\"}"
14360        );
14361    }
14362
14363    #[tokio::test]
14364    async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
14365        let request = chat_request(json!({
14366            "response_format": {
14367                "type": "json_schema",
14368                "json_schema": {
14369                    "name": "answer",
14370                    "strict": true,
14371                    "schema": {
14372                        "type": "object",
14373                        "properties": {"answer": {"type": "string"}},
14374                        "required": ["answer"]
14375                    }
14376                }
14377            }
14378        }));
14379        let response = chat_completions_handler(
14380            State(state_with_stub(
14381                "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
14382            )),
14383            HeaderMap::new(),
14384            Ok(Json(request)),
14385        )
14386        .await
14387        .expect("strict response with reasoning");
14388        assert_eq!(response.status(), AxumStatusCode::OK);
14389        let body = response_json(response).await;
14390        assert_eq!(
14391            body["choices"][0]["message"]["content"],
14392            "{\"answer\":\"yes\"}"
14393        );
14394        assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
14395    }
14396
14397    #[tokio::test]
14398    async fn strict_json_schema_validates_streaming_final_response() {
14399        let response = post_json(
14400            router_with_stub("{\"answer\":\"yes\"}"),
14401            "/v1/chat/completions",
14402            json!({
14403                "model": "stub-model",
14404                "messages": [{"role": "user", "content": "Return an answer object."}],
14405                "stream": true,
14406                "response_format": {
14407                    "type": "json_schema",
14408                    "json_schema": {
14409                        "name": "answer",
14410                        "strict": true,
14411                        "schema": {
14412                            "type": "object",
14413                            "properties": {"answer": {"type": "string"}},
14414                            "required": ["answer"]
14415                        }
14416                    }
14417                }
14418            }),
14419        )
14420        .await;
14421        assert_eq!(response.status(), AxumStatusCode::OK);
14422        let body = response_text(response).await;
14423        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14424        assert!(
14425            body.contains("\\\"answer\\\":\\\"yes\\\""),
14426            "strict streaming content missing: {body}"
14427        );
14428        assert!(
14429            !body.contains("\"error\""),
14430            "valid strict streaming response should not emit error: {body}"
14431        );
14432    }
14433
14434    #[tokio::test]
14435    async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
14436        let response = post_json(
14437            router_with_stub_stream_chunks(&[
14438                "<think>\nreasoning",
14439                "\n</think>\n\n",
14440                "{\"answer\":\"yes\"}",
14441            ]),
14442            "/v1/chat/completions",
14443            json!({
14444                "model": "stub-model",
14445                "messages": [{"role": "user", "content": "Return an answer object."}],
14446                "stream": true,
14447                "response_format": {
14448                    "type": "json_schema",
14449                    "json_schema": {
14450                        "name": "answer",
14451                        "strict": true,
14452                        "schema": {
14453                            "type": "object",
14454                            "properties": {"answer": {"type": "string"}},
14455                            "required": ["answer"]
14456                        }
14457                    }
14458                }
14459            }),
14460        )
14461        .await;
14462        assert_eq!(response.status(), AxumStatusCode::OK);
14463        let body = response_text(response).await;
14464        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14465        assert!(
14466            body.contains("\\\"answer\\\":\\\"yes\\\""),
14467            "strict streaming content missing: {body}"
14468        );
14469        assert!(
14470            body.contains(r#""reasoning":"\nreasoning\n""#),
14471            "strict streaming should keep reasoning separate: {body}"
14472        );
14473        assert!(
14474            !body.contains("\"error\""),
14475            "valid strict streaming response should not emit error: {body}"
14476        );
14477    }
14478
14479    #[tokio::test]
14480    async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
14481        let response = post_json(
14482            router_with_stub("not json"),
14483            "/v1/chat/completions",
14484            json!({
14485                "model": "stub-model",
14486                "messages": [{"role": "user", "content": "Return an answer object."}],
14487                "stream": true,
14488                "response_format": {
14489                    "type": "json_schema",
14490                    "json_schema": {
14491                        "name": "answer",
14492                        "strict": true,
14493                        "schema": {
14494                            "type": "object",
14495                            "properties": {"answer": {"type": "string"}},
14496                            "required": ["answer"]
14497                        }
14498                    }
14499                }
14500            }),
14501        )
14502        .await;
14503        assert_eq!(response.status(), AxumStatusCode::OK);
14504        let body = response_text(response).await;
14505        assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14506        assert!(
14507            body.contains("\"type\":\"internal_server_error\""),
14508            "strict streaming validation failure should emit OpenAI error: {body}"
14509        );
14510        assert!(
14511            body.contains("\"param\":\"response_format.json_schema\""),
14512            "strict streaming validation error should identify schema param: {body}"
14513        );
14514        assert!(
14515            body.contains("invalid JSON"),
14516            "strict streaming validation should report invalid JSON: {body}"
14517        );
14518        assert!(
14519            !body.contains("not json"),
14520            "strict streaming must not emit invalid partial deltas before validation failure: {body}"
14521        );
14522    }
14523
14524    #[tokio::test]
14525    async fn route_strict_json_schema_supported_schema_passes_100_runs() {
14526        let request_body = json!({
14527            "model": "stub-model",
14528            "messages": [{"role": "user", "content": "Return an answer object."}],
14529            "response_format": {
14530                "type": "json_schema",
14531                "json_schema": {
14532                    "name": "answer",
14533                    "strict": true,
14534                    "schema": {
14535                        "type": "object",
14536                        "properties": {"answer": {"type": "string"}},
14537                        "required": ["answer"]
14538                    }
14539                }
14540            }
14541        });
14542        let router = router_with_stub("{\"answer\":\"yes\"}");
14543        for run in 0..100 {
14544            let response =
14545                post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
14546            assert_eq!(
14547                response.status(),
14548                AxumStatusCode::OK,
14549                "strict schema run {run} returned non-200"
14550            );
14551            let body = response_json(response).await;
14552            let content = body["choices"][0]["message"]["content"]
14553                .as_str()
14554                .unwrap_or("");
14555            assert_eq!(
14556                content, "{\"answer\":\"yes\"}",
14557                "strict schema run {run} returned unexpected content"
14558            );
14559            let parsed: serde_json::Value =
14560                serde_json::from_str(content).expect("strict content JSON");
14561            assert_eq!(parsed["answer"], "yes");
14562        }
14563    }
14564
14565    #[test]
14566    fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
14567        let cache = CacheRuntimeState::default();
14568        let policy = CachePolicy {
14569            prefix_cache_enabled: true,
14570            session_cache_mode: "memory".to_string(),
14571            session_cache_max_entries: 128,
14572            session_cache_max_tokens: 4096,
14573        };
14574        cache.record_prefix_prompt("alpha beta gamma", &policy);
14575        cache.record_prefix_prompt("alpha beta delta", &policy);
14576
14577        let engine_snapshot = json!({
14578            "position": "real-kv-reuse",
14579            "source": "llama-family-paged-block-prefix-cache",
14580            "enabled": true,
14581            "hits": 7,
14582            "misses": 3,
14583            "evictions": 1,
14584            "saved_prefill_tokens": 64,
14585            "entries": 5,
14586            "bytes": 8192,
14587            "block_size": 16,
14588            "kv_dtype": "fp16",
14589            "selected_pipeline_mode": "batch",
14590            "selected_stage_bridge": "host",
14591            "stage_count": 2,
14592        });
14593
14594        let health = cache.health_json(&policy, Some(&engine_snapshot));
14595        let prefix = &health["prefix_cache"];
14596        assert_eq!(prefix["position"], "real-kv-reuse");
14597        assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
14598        assert_eq!(prefix["hits"], 7);
14599        assert_eq!(prefix["misses"], 3);
14600        assert_eq!(prefix["evictions"], 1);
14601        assert_eq!(prefix["saved_prefill_tokens"], 64);
14602        assert_eq!(prefix["entries"], 5);
14603        assert_eq!(prefix["bytes"], 8192);
14604        assert_eq!(prefix["block_size"], 16);
14605        assert_eq!(prefix["kv_dtype"], "fp16");
14606        assert_eq!(prefix["selected_pipeline_mode"], "batch");
14607        assert_eq!(prefix["selected_stage_bridge"], "host");
14608        assert_eq!(prefix["stage_count"], 2);
14609
14610        let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
14611        assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
14612        assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
14613        assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
14614        assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
14615        assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
14616    }
14617
14618    #[tokio::test]
14619    async fn strict_json_schema_invalid_model_output_fails_before_response() {
14620        let request = chat_request(json!({
14621            "response_format": {
14622                "type": "json_schema",
14623                "json_schema": {
14624                    "name": "answer",
14625                    "strict": true,
14626                    "schema": {
14627                        "type": "object",
14628                        "properties": {"answer": {"type": "string"}},
14629                        "required": ["answer"]
14630                    }
14631                }
14632            }
14633        }));
14634        let err = chat_completions_handler(
14635            State(state_with_stub("not json")),
14636            HeaderMap::new(),
14637            Ok(Json(request)),
14638        )
14639        .await
14640        .expect_err("invalid strict response should fail");
14641        let (status, body) = error_json(err).await;
14642        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14643        assert_eq!(body["error"]["type"], "internal_server_error");
14644        assert!(body["error"]["message"]
14645            .as_str()
14646            .unwrap()
14647            .contains("json_schema.strict"));
14648    }
14649
14650    #[tokio::test]
14651    async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
14652        let request = chat_request(json!({
14653            "response_format": {
14654                "type": "json_schema",
14655                "json_schema": {
14656                    "name": "answer",
14657                    "strict": true,
14658                    "schema": {
14659                        "type": "object",
14660                        "properties": {"answer": {"type": "string"}},
14661                        "required": ["answer"]
14662                    }
14663                }
14664            }
14665        }));
14666        let err = chat_completions_handler(
14667            State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
14668            HeaderMap::new(),
14669            Ok(Json(request)),
14670        )
14671        .await
14672        .expect_err("strict schema should fail fenced JSON instead of repairing it");
14673        let (status, body) = error_json(err).await;
14674        assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14675        assert_eq!(body["error"]["type"], "internal_server_error");
14676        assert!(body["error"]["message"]
14677            .as_str()
14678            .unwrap()
14679            .contains("json_schema.strict: invalid JSON"));
14680    }
14681}