1use crate::{
7 chat_template::{
8 render_chat_prompt_with_model_template_options,
9 render_chat_prompt_with_tools_and_model_template, ChatTemplateOptions, ModelChatTemplate,
10 ModelReasoningProtocol,
11 },
12 model_registry::{LoraAdapterModel, ServedModelKind, ServedModelRegistry},
13 openai::*,
14 traits::HttpServer,
15 types::*,
16};
17use async_trait::async_trait;
18use axum::{
19 extract::{multipart::MultipartRejection, rejection::JsonRejection, State},
20 http::{HeaderMap, StatusCode as AxumStatusCode},
21 response::{sse::Event, IntoResponse, Response, Sse},
22 routing::{get, post},
23 Json, Router,
24};
25use ferrum_interfaces::engine::{EmbedEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine};
26use ferrum_types::{
27 has_unclosed_thinking_block, parse_reasoning_response,
28 parse_reasoning_response_started_in_think, EngineMetrics, EngineStatus, FerrumConfigBuilder,
29 FerrumError as Error, FerrumProfileEvent, FinishReason, InferenceExecutionEvidence,
30 InferenceRequest, InferenceResponse, ModelId, ParsedReasoningResponse, Priority,
31 ProcessMemoryObservation, ProcessMemorySample, ProcessMemorySampler, ProfileEntrypoint,
32 ProfileError, ProfileEventKind, ProfileStatus, ReplayReference, RequestId,
33 ResolvedFerrumConfig, ResourceAction, ResourceTraceEvent, ResponseCompletionBoundary,
34 RuntimeConfigSnapshot, SamplingParams, StructuredOutputStart, TokenId, TokenUsage,
35 DEFAULT_CHAT_REPETITION_PENALTY, DEFAULT_MAX_TOKENS_METADATA_KEY,
36 OBSERVABILITY_PROFILE_SCHEMA_VERSION, THINK_END_TAG, THINK_START_TAG,
37};
38use sha2::{Digest, Sha256};
39use std::{
40 collections::{BTreeMap, HashMap},
41 error::Error as StdError,
42 fs,
43 path::{Path, PathBuf},
44 sync::{
45 atomic::{AtomicBool, Ordering},
46 Arc, Mutex, OnceLock,
47 },
48 time::Instant,
49};
50use tokio::sync::{mpsc, Notify};
51use tokio_stream::StreamExt;
52use tower::ServiceBuilder;
53use tower_http::{cors::CorsLayer, trace::TraceLayer};
54use tracing::{debug, error, info, span, warn, Level};
55use uuid::Uuid;
56
57mod responses;
58
59const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
60const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
61const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 4096;
62const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
63const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
64const MAX_CACHED_JSON_SCHEMA_VALIDATORS: usize = 64;
65const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
66 &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
67const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
68static JSON_SCHEMA_VALIDATOR_CACHE: OnceLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
69 OnceLock::new();
70
71pub fn default_chat_sampling_params() -> SamplingParams {
75 SamplingParams {
76 max_tokens: DEFAULT_COMPLETION_MAX_TOKENS as usize,
77 temperature: DEFAULT_SAMPLING_TEMPERATURE,
78 top_p: DEFAULT_SAMPLING_TOP_P,
79 repetition_penalty: DEFAULT_CHAT_REPETITION_PENALTY,
80 ..SamplingParams::default()
81 }
82}
83
84#[derive(Debug, Clone)]
85struct CachePolicy {
86 prefix_cache_enabled: bool,
87 session_cache_mode: String,
88 session_cache_max_entries: usize,
89 session_cache_max_tokens: usize,
90}
91
92impl CachePolicy {
93 fn current() -> Self {
94 Self {
95 prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
96 .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
97 .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
98 .unwrap_or(false),
99 session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
100 .unwrap_or_else(|_| "off".to_string())
101 .to_ascii_lowercase(),
102 session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
103 session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
104 }
105 }
106
107 fn session_memory_enabled(&self) -> bool {
108 self.session_cache_mode == "memory"
109 }
110}
111
112fn env_bool(key: &str) -> Option<bool> {
113 match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
114 "1" | "true" | "yes" | "on" => Some(true),
115 "0" | "false" | "no" | "off" => Some(false),
116 _ => None,
117 }
118}
119
120fn env_usize(key: &str) -> Option<usize> {
121 std::env::var(key).ok()?.parse().ok()
122}
123
124static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
126 std::sync::OnceLock::new();
127
128pub fn init_prometheus_recorder() {
133 PROM_HANDLE.get_or_init(|| {
134 let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
135 let handle = builder
136 .install_recorder()
137 .expect("Failed to install Prometheus recorder");
138 info!("Prometheus metrics recorder installed");
139 handle
140 });
141}
142
143pub struct AxumServer {
149 state: AppState,
150 config: ServerConfig,
151 lifecycle: Arc<AxumServerLifecycle>,
152}
153
154#[derive(Default)]
155struct AxumServerLifecycle {
156 shutdown_requested: AtomicBool,
157 running: AtomicBool,
158 engines_stopped: AtomicBool,
159 shutdown_notify: Notify,
160 stopped_notify: Notify,
161 stop_lock: tokio::sync::Mutex<()>,
162}
163
164impl AxumServerLifecycle {
165 fn request_shutdown(&self) {
166 self.shutdown_requested.store(true, Ordering::Release);
167 self.shutdown_notify.notify_waiters();
168 }
169
170 async fn wait_for_shutdown(&self) {
171 while !self.shutdown_requested.load(Ordering::Acquire) {
172 self.shutdown_notify.notified().await;
173 }
174 }
175
176 async fn wait_until_stopped(&self) {
177 while self.running.load(Ordering::Acquire) {
178 self.stopped_notify.notified().await;
179 }
180 }
181}
182
183struct AxumServerRunGuard {
184 lifecycle: Arc<AxumServerLifecycle>,
185}
186
187impl Drop for AxumServerRunGuard {
188 fn drop(&mut self) {
189 self.lifecycle.running.store(false, Ordering::Release);
190 self.lifecycle.stopped_notify.notify_waiters();
191 }
192}
193
194fn single_model_registry(engine_model_id: ModelId, kind: ServedModelKind) -> ServedModelRegistry {
195 let public_name = engine_model_id.to_string();
196 ServedModelRegistry::try_new(engine_model_id, kind, vec![public_name], vec![])
197 .expect("engine config must contain a valid model id")
198}
199
200impl AxumServer {
201 pub fn from_state(state: AppState) -> Self {
203 Self {
204 state,
205 config: ServerConfig::default(),
206 lifecycle: Arc::new(AxumServerLifecycle::default()),
207 }
208 }
209
210 pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
212 Self::from_state(AppState::default().with_llm(engine))
213 }
214
215 pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
217 Self::from_state(AppState::default().with_embed(engine))
218 }
219
220 pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
223 Self::from_state(AppState::default().with_transcribe(engine))
224 }
225
226 pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
228 Self::from_state(AppState::default().with_tts(engine))
229 }
230
231 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
235 self.state = self.state.with_auto_config(auto_config);
236 self
237 }
238
239 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
242 self.state = self.state.with_prompt_template(prompt_template);
243 self
244 }
245
246 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
249 self.state = self.state.with_default_enable_thinking(enable_thinking);
250 self
251 }
252
253 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
257 self.state = self.state.with_served_model_registry(registry);
258 self
259 }
260
261 pub fn with_lora_adapters(
263 mut self,
264 base_model_id: impl Into<String>,
265 adapters: Vec<LoraAdapterModel>,
266 ) -> ferrum_types::Result<Self> {
267 let base_model_id = base_model_id.into();
268 let registry = if self.state.served_model_registry.is_empty() {
269 ServedModelRegistry::try_new(
270 base_model_id.clone(),
271 ServedModelKind::Llm,
272 vec![base_model_id],
273 adapters,
274 )
275 } else {
276 self.state
277 .served_model_registry
278 .try_with_lora_adapters(&base_model_id, adapters)
279 }
280 .map_err(|error| Error::config(error.to_string()))?;
281 self.state = self.state.with_served_model_registry(registry);
282 Ok(self)
283 }
284
285 async fn shutdown_loaded_engines(&self) -> ferrum_types::Result<()> {
286 let mut first_error = None;
287 if let Some(engine) = &self.state.llm {
288 if let Err(error) = engine.shutdown().await {
289 first_error = Some(error);
290 }
291 }
292 if let Some(engine) = &self.state.embed {
293 if let Err(error) = engine.shutdown().await {
294 if first_error.is_none() {
295 first_error = Some(error);
296 }
297 }
298 }
299 if let Some(engine) = &self.state.transcribe {
300 if let Err(error) = engine.shutdown().await {
301 if first_error.is_none() {
302 first_error = Some(error);
303 }
304 }
305 }
306 if let Some(engine) = &self.state.tts {
307 if let Err(error) = engine.shutdown().await {
308 if first_error.is_none() {
309 first_error = Some(error);
310 }
311 }
312 }
313 first_error.map_or(Ok(()), Err)
314 }
315
316 #[allow(dead_code)]
318 fn build_router(&self) -> Router {
319 self.build_router_with_state(self.state.clone())
320 }
321
322 fn build_router_with_state(&self, app_state: AppState) -> Router {
323 Router::new()
324 .route("/v1/chat/completions", post(chat_completions_handler))
326 .route("/v1/responses", post(responses::responses_handler))
327 .route("/v1/completions", post(completions_handler))
328 .route("/v1/embeddings", post(embeddings_handler))
329 .route("/v1/audio/transcriptions", post(transcriptions_handler))
330 .route("/v1/audio/speech", post(speech_handler))
331 .route("/v1/models", get(models_handler))
332 .route("/health", get(health_handler))
334 .route("/metrics", get(metrics_handler))
335 .route("/", get(root_handler))
336 .layer(
338 ServiceBuilder::new()
339 .layer(TraceLayer::new_for_http())
340 .layer(CorsLayer::permissive()), )
342 .with_state(app_state)
343 }
344}
345
346#[derive(Clone, Default)]
350pub struct AppState {
351 pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
352 pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
353 pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
354 pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
355 pub auto_config: Option<ResolvedFerrumConfig>,
356 pub prompt_template: Option<Arc<ModelChatTemplate>>,
357 pub default_enable_thinking: Option<bool>,
358 pub served_model_registry: Arc<ServedModelRegistry>,
359 pub request_dump_dir: Option<Arc<PathBuf>>,
360 pub profile_jsonl: Option<Arc<PathBuf>>,
361 pub profile_detail: ferrum_types::ObservabilityProfileDetail,
362 pub memory_profile_jsonl: Option<Arc<PathBuf>>,
363 pub first_request_memory_recorded: Arc<AtomicBool>,
364 cache: Arc<CacheRuntimeState>,
365}
366
367impl AppState {
368 pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
369 if self.served_model_registry.is_empty() {
370 self.served_model_registry = Arc::new(single_model_registry(
371 engine.config().model.model_id.clone(),
372 ServedModelKind::Llm,
373 ));
374 }
375 self.llm = Some(engine);
376 self
377 }
378 pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
379 if self.served_model_registry.is_empty() {
380 self.served_model_registry = Arc::new(single_model_registry(
381 engine.config().model.model_id.clone(),
382 ServedModelKind::Embedding,
383 ));
384 }
385 self.embed = Some(engine);
386 self
387 }
388 pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
389 if self.served_model_registry.is_empty() {
390 self.served_model_registry = Arc::new(single_model_registry(
391 engine.config().model.model_id.clone(),
392 ServedModelKind::Transcription,
393 ));
394 }
395 self.transcribe = Some(engine);
396 self
397 }
398 pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
399 if self.served_model_registry.is_empty() {
400 self.served_model_registry = Arc::new(single_model_registry(
401 engine.config().model.model_id.clone(),
402 ServedModelKind::Speech,
403 ));
404 }
405 self.tts = Some(engine);
406 self
407 }
408
409 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
410 self.auto_config = Some(auto_config);
411 self
412 }
413
414 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
415 self.prompt_template = prompt_template.map(Arc::new);
416 self
417 }
418
419 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
420 self.default_enable_thinking = enable_thinking;
421 self
422 }
423
424 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
425 self.served_model_registry = Arc::new(registry);
426 self
427 }
428
429 pub fn with_request_dump_dir(mut self, request_dump_dir: Option<PathBuf>) -> Self {
430 self.request_dump_dir = request_dump_dir.map(Arc::new);
431 self
432 }
433
434 pub fn with_profile_jsonl(mut self, profile_jsonl: Option<PathBuf>) -> Self {
435 self.profile_jsonl = profile_jsonl.map(Arc::new);
436 self
437 }
438
439 pub fn with_profile_detail(
440 mut self,
441 profile_detail: ferrum_types::ObservabilityProfileDetail,
442 ) -> Self {
443 self.profile_detail = profile_detail;
444 self
445 }
446
447 pub fn with_memory_profile_jsonl(mut self, memory_profile_jsonl: Option<PathBuf>) -> Self {
448 self.memory_profile_jsonl = memory_profile_jsonl.map(Arc::new);
449 self
450 }
451
452 async fn status(&self) -> EngineStatus {
455 if let Some(e) = &self.llm {
456 return e.status().await;
457 }
458 if let Some(e) = &self.embed {
459 return e.status().await;
460 }
461 if let Some(e) = &self.transcribe {
462 return e.status().await;
463 }
464 if let Some(e) = &self.tts {
465 return e.status().await;
466 }
467 EngineStatus {
468 is_ready: false,
469 loaded_models: vec![],
470 active_requests: 0,
471 queued_requests: 0,
472 memory_usage: ferrum_types::MemoryUsage {
473 total_bytes: 0,
474 used_bytes: 0,
475 free_bytes: 0,
476 gpu_memory_bytes: None,
477 cpu_memory_bytes: None,
478 cache_memory_bytes: 0,
479 utilization_percent: 0.0,
480 },
481 uptime_seconds: 0,
482 last_heartbeat: chrono::Utc::now(),
483 version: env!("CARGO_PKG_VERSION").to_string(),
484 }
485 }
486
487 fn metrics(&self) -> EngineMetrics {
488 if let Some(e) = &self.llm {
489 return e.metrics();
490 }
491 if let Some(e) = &self.embed {
492 return e.metrics();
493 }
494 if let Some(e) = &self.transcribe {
495 return e.metrics();
496 }
497 if let Some(e) = &self.tts {
498 return e.metrics();
499 }
500 EngineMetrics {
501 total_requests: 0,
502 successful_requests: 0,
503 failed_requests: 0,
504 avg_request_latency_ms: 0.0,
505 p95_request_latency_ms: 0.0,
506 p99_request_latency_ms: 0.0,
507 throughput_rps: 0.0,
508 tokens_per_second: 0.0,
509 queue_metrics: Default::default(),
510 resource_utilization: Default::default(),
511 error_stats: Default::default(),
512 performance_breakdown: Default::default(),
513 }
514 }
515}
516
517#[derive(Default)]
518struct CacheRuntimeState {
519 stats: Mutex<CacheStats>,
520 prefix_prompts: Mutex<HashMap<String, usize>>,
521 sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
522}
523
524#[derive(Debug, Clone, Default)]
525struct CacheStats {
526 prefix_hits: u64,
527 prefix_misses: u64,
528 prefix_evictions: u64,
529 prefix_saved_prefill_tokens: u64,
530 prefix_entries: u64,
531 prefix_bytes: u64,
532 session_hits: u64,
533 session_misses: u64,
534 session_evictions: u64,
535 session_entries: u64,
536 session_tokens: u64,
537}
538
539#[derive(Clone)]
540struct SessionContext {
541 id: String,
542 prior_messages: Vec<ChatMessage>,
543 incoming_messages: Vec<ChatMessage>,
544}
545
546impl CacheRuntimeState {
547 fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
548 if !policy.prefix_cache_enabled {
549 return;
550 }
551
552 let prompt_tokens = approx_tokens(prompt);
553 let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
554 let saved_tokens = prompts
555 .keys()
556 .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
557 .max()
558 .unwrap_or(0);
559
560 let mut stats = self.stats.lock().expect("cache stats lock");
561 if saved_tokens > 0 {
562 stats.prefix_hits += 1;
563 stats.prefix_saved_prefill_tokens += saved_tokens as u64;
564 } else {
565 stats.prefix_misses += 1;
566 }
567
568 let max_entries = policy.session_cache_max_entries.max(1);
569 if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
570 if let Some(key) = prompts.keys().next().cloned() {
571 prompts.remove(&key);
572 stats.prefix_evictions += 1;
573 }
574 }
575 prompts.insert(prompt.to_string(), prompt_tokens);
576 stats.prefix_entries = prompts.len() as u64;
577 stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
578 }
579
580 fn prepare_session_request(
581 &self,
582 request: &mut ChatCompletionsRequest,
583 headers: &HeaderMap,
584 policy: &CachePolicy,
585 ) -> Option<SessionContext> {
586 let session_id = request_session_id(headers, request)?;
587 if !policy.session_memory_enabled() {
588 return None;
589 }
590
591 let incoming_messages = request.messages.clone();
592 let prior_messages = {
593 let sessions = self.sessions.lock().expect("session cache lock");
594 sessions.get(&session_id).cloned().unwrap_or_default()
595 };
596 {
597 let mut stats = self.stats.lock().expect("cache stats lock");
598 if prior_messages.is_empty() {
599 stats.session_misses += 1;
600 } else {
601 stats.session_hits += 1;
602 let mut merged = prior_messages.clone();
603 merged.extend(request.messages.clone());
604 request.messages = merged;
605 }
606 }
607
608 Some(SessionContext {
609 id: session_id,
610 prior_messages,
611 incoming_messages,
612 })
613 }
614
615 fn update_session(
616 &self,
617 context: Option<SessionContext>,
618 assistant_message: ChatMessage,
619 policy: &CachePolicy,
620 ) {
621 let Some(context) = context else {
622 return;
623 };
624 if !policy.session_memory_enabled() {
625 return;
626 }
627
628 let mut history = context.prior_messages;
629 history.extend(context.incoming_messages);
630 history.push(assistant_message);
631 trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
632
633 let mut sessions = self.sessions.lock().expect("session cache lock");
634 if !sessions.contains_key(&context.id)
635 && sessions.len() >= policy.session_cache_max_entries.max(1)
636 {
637 if let Some(evict_key) = sessions.keys().next().cloned() {
638 sessions.remove(&evict_key);
639 self.stats
640 .lock()
641 .expect("cache stats lock")
642 .session_evictions += 1;
643 }
644 }
645 sessions.insert(context.id, history);
646
647 let entries = sessions.len() as u64;
648 let tokens = sessions
649 .values()
650 .map(|messages| {
651 messages
652 .iter()
653 .map(|msg| approx_tokens(&msg.content))
654 .sum::<usize>()
655 })
656 .sum::<usize>() as u64;
657 let mut stats = self.stats.lock().expect("cache stats lock");
658 stats.session_entries = entries;
659 stats.session_tokens = tokens;
660 }
661
662 fn stats(&self) -> CacheStats {
663 let mut stats = self.stats.lock().expect("cache stats lock").clone();
664 stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
665 let sessions = self.sessions.lock().expect("session cache lock");
666 stats.session_entries = sessions.len() as u64;
667 stats.session_tokens = sessions
668 .values()
669 .map(|messages| {
670 messages
671 .iter()
672 .map(|msg| approx_tokens(&msg.content))
673 .sum::<usize>()
674 })
675 .sum::<usize>() as u64;
676 stats
677 }
678
679 fn health_json(
680 &self,
681 policy: &CachePolicy,
682 engine_prefix_cache: Option<&serde_json::Value>,
683 ) -> serde_json::Value {
684 let stats = self.stats();
685 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
686 let prefix_misses =
687 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
688 let prefix_evictions =
689 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
690 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
691 .unwrap_or(stats.prefix_saved_prefill_tokens);
692 let prefix_entries =
693 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
694 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
695 let mut prefix_cache = serde_json::json!({
696 "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
697 "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
698 "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
699 "entries": prefix_entries,
700 "hits": prefix_hits,
701 "misses": prefix_misses,
702 "evictions": prefix_evictions,
703 "saved_prefill_tokens": prefix_saved,
704 "bytes": prefix_bytes,
705 "block_size": engine_u64(engine_prefix_cache, "block_size"),
706 "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
707 });
708 if let (Some(engine), Some(prefix)) = (
709 engine_prefix_cache.and_then(|value| value.as_object()),
710 prefix_cache.as_object_mut(),
711 ) {
712 for (key, value) in engine {
713 prefix.entry(key.clone()).or_insert_with(|| value.clone());
714 }
715 }
716 serde_json::json!({
717 "prefix_cache": prefix_cache,
718 "session_cache": {
719 "mode": policy.session_cache_mode,
720 "entries": stats.session_entries,
721 "hits": stats.session_hits,
722 "misses": stats.session_misses,
723 "evictions": stats.session_evictions,
724 "tokens": stats.session_tokens,
725 "max_entries": policy.session_cache_max_entries,
726 "max_tokens": policy.session_cache_max_tokens,
727 }
728 })
729 }
730
731 fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
732 let stats = self.stats();
733 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
734 let prefix_misses =
735 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
736 let prefix_evictions =
737 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
738 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
739 .unwrap_or(stats.prefix_saved_prefill_tokens);
740 let prefix_entries =
741 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
742 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
743 format!(
744 concat!(
745 "ferrum_prefix_cache_hits_total {}\n",
746 "ferrum_prefix_cache_misses_total {}\n",
747 "ferrum_prefix_cache_evictions_total {}\n",
748 "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
749 "ferrum_prefix_cache_entries {}\n",
750 "ferrum_prefix_cache_bytes {}\n",
751 "ferrum_session_cache_hits_total {}\n",
752 "ferrum_session_cache_misses_total {}\n",
753 "ferrum_session_cache_evictions_total {}\n",
754 "ferrum_session_cache_entries {}\n",
755 "ferrum_session_cache_tokens {}\n"
756 ),
757 prefix_hits,
758 prefix_misses,
759 prefix_evictions,
760 prefix_saved,
761 prefix_entries,
762 prefix_bytes,
763 stats.session_hits,
764 stats.session_misses,
765 stats.session_evictions,
766 stats.session_entries,
767 stats.session_tokens,
768 )
769 }
770}
771
772fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
773 snapshot?.get(key)?.as_u64()
774}
775
776fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
777 snapshot?.get(key)?.as_bool()
778}
779
780fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
781 snapshot?.get(key)?.as_str()
782}
783
784fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
785 match auto_config {
786 Some(auto_config) => auto_config.effective_config_document(),
787 None => {
788 match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
789 Ok(auto_config) => auto_config.effective_config_document(),
790 Err(err) => serde_json::json!({
791 "schema_version": 1,
792 "error": err.to_string(),
793 }),
794 }
795 }
796 }
797}
798
799fn admission_health_json(
800 engine_status: &EngineStatus,
801 scheduler_metrics: &EngineMetrics,
802 auto_config: &serde_json::Value,
803 runtime_snapshot: Option<&ferrum_types::ExecutorAdmissionSnapshot>,
804 runtime_error: Option<&str>,
805) -> serde_json::Value {
806 let configured = auto_config
807 .get("admission")
808 .and_then(|value| value.as_object());
809 let preflight_effective_max_concurrent = configured
810 .and_then(|value| value.get("effective_max_concurrent"))
811 .and_then(|value| value.as_u64());
812 let effective_max_concurrent = if runtime_error.is_some() {
813 None
814 } else {
815 Some(
816 runtime_snapshot
817 .map(|snapshot| u64::from(snapshot.maximum_active_sequences()))
818 .or(preflight_effective_max_concurrent)
819 .unwrap_or_else(|| {
820 (engine_status.active_requests + engine_status.queued_requests)
821 .max(1)
822 .try_into()
823 .unwrap_or(u64::MAX)
824 }),
825 )
826 };
827 let active_sequences = runtime_error.is_none().then(|| {
828 runtime_snapshot
829 .map(|snapshot| u64::from(snapshot.active_sequences()))
830 .unwrap_or_else(|| engine_status.active_requests as u64)
831 });
832 let waiting_requests = runtime_error.is_none().then(|| {
833 runtime_snapshot
834 .map(|snapshot| u64::from(snapshot.waiting_requests()))
835 .unwrap_or_else(|| engine_status.queued_requests as u64)
836 });
837 serde_json::json!({
838 "schema_version": 2,
839 "source": if runtime_error.is_some() {
840 "runtime_error"
841 } else if runtime_snapshot.is_some() {
842 "runtime_executor"
843 } else {
844 "startup_preflight_and_engine_status"
845 },
846 "runtime_snapshot_available": runtime_snapshot.is_some(),
847 "runtime_contract_error": runtime_error,
848 "resource_authority": runtime_snapshot
849 .and_then(|snapshot| serde_json::to_value(snapshot.resource_authority()).ok())
850 .unwrap_or(serde_json::Value::Null),
851 "effective_max_concurrent": effective_max_concurrent,
852 "maximum_active_sequences": runtime_snapshot
853 .map(|snapshot| u64::from(snapshot.maximum_active_sequences())),
854 "maximum_scheduled_tokens": runtime_snapshot
855 .map(|snapshot| snapshot.maximum_scheduled_tokens()),
856 "preflight_effective_max_concurrent": preflight_effective_max_concurrent,
857 "queue_depth": waiting_requests,
858 "active_sequences": active_sequences,
859 "active_prefill": runtime_snapshot
860 .map(|snapshot| u64::from(snapshot.active_prefill_sequences())),
861 "active_decode": runtime_snapshot
862 .map(|snapshot| u64::from(snapshot.active_decode_sequences())),
863 "current_batch_size": runtime_snapshot
864 .and_then(|snapshot| snapshot.current_batch_size())
865 .map(u64::from),
866 "capacity_blocked_requests": runtime_snapshot
867 .and_then(|snapshot| snapshot.capacity_blocked_requests())
868 .map(u64::from),
869 "rejected_requests_total": 0u64,
870 "failed_requests_total": scheduler_metrics.failed_requests,
871 "completed_requests_total": scheduler_metrics.successful_requests,
872 "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
873 "scheduler_policy": configured
874 .and_then(|value| value.get("scheduler_policy"))
875 .and_then(|value| value.as_str())
876 .unwrap_or("unknown"),
877 "phase_detail_source": if runtime_snapshot.is_some() {
878 "scheduler_request_index_single_read"
879 } else {
880 "unavailable"
881 },
882 })
883}
884
885fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
886 let snapshot_available = u8::from(
887 admission
888 .get("runtime_snapshot_available")
889 .and_then(serde_json::Value::as_bool)
890 .unwrap_or(false),
891 );
892 let mut output = format!("ferrum_admission_runtime_snapshot_available {snapshot_available}\n");
893 for (field, metric) in [
894 (
895 "effective_max_concurrent",
896 "ferrum_admission_effective_max_concurrent",
897 ),
898 (
899 "maximum_active_sequences",
900 "ferrum_admission_maximum_active_sequences",
901 ),
902 (
903 "maximum_scheduled_tokens",
904 "ferrum_admission_maximum_scheduled_tokens",
905 ),
906 ("queue_depth", "ferrum_admission_queue_depth"),
907 (
908 "capacity_blocked_requests",
909 "ferrum_admission_capacity_blocked_requests",
910 ),
911 ("active_sequences", "ferrum_admission_active_sequences"),
912 ("active_prefill", "ferrum_admission_active_prefill"),
913 ("active_decode", "ferrum_admission_active_decode"),
914 ("current_batch_size", "ferrum_admission_current_batch_size"),
915 (
916 "rejected_requests_total",
917 "ferrum_admission_rejected_requests_total",
918 ),
919 (
920 "failed_requests_total",
921 "ferrum_admission_failed_requests_total",
922 ),
923 (
924 "completed_requests_total",
925 "ferrum_admission_completed_requests_total",
926 ),
927 ] {
928 if let Some(value) = admission.get(field).and_then(serde_json::Value::as_u64) {
929 output.push_str(&format!("{metric} {value}\n"));
930 }
931 }
932 output
933}
934
935fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
936 headers
937 .get(FERRUM_SESSION_HEADER)
938 .and_then(|value| value.to_str().ok())
939 .map(str::trim)
940 .filter(|value| !value.is_empty())
941 .map(str::to_string)
942 .or_else(|| {
943 request
944 .metadata
945 .as_ref()
946 .and_then(|metadata| metadata.get("ferrum_session_id"))
947 .and_then(|value| value.as_str())
948 .map(str::trim)
949 .filter(|value| !value.is_empty())
950 .map(str::to_string)
951 })
952}
953
954fn approx_tokens(text: &str) -> usize {
955 approx_tokens_for_chars(text.chars().count())
956}
957
958fn approx_tokens_for_chars(chars: usize) -> usize {
959 (chars / 4).max(1)
960}
961
962fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
963 a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
964}
965
966fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
967 let max_tokens = max_tokens.max(1);
968 while messages.len() > 1
969 && messages
970 .iter()
971 .map(|msg| approx_tokens(&msg.content))
972 .sum::<usize>()
973 > max_tokens
974 {
975 messages.remove(0);
976 }
977}
978
979#[async_trait]
980impl HttpServer for AxumServer {
981 async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
982 if self.lifecycle.shutdown_requested.load(Ordering::Acquire) {
983 return Err(Error::internal(
984 "cannot start Axum server after shutdown was requested",
985 ));
986 }
987 self.lifecycle
988 .running
989 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
990 .map_err(|_| Error::internal("Axum server is already running"))?;
991 let _run_guard = AxumServerRunGuard {
992 lifecycle: Arc::clone(&self.lifecycle),
993 };
994 let addr = format!("{}:{}", config.host, config.port);
995 info!("Starting Axum server on {}", addr);
996
997 let app = self.build_router_with_state(
998 self.state
999 .clone()
1000 .with_request_dump_dir(config.request_dump_dir.clone())
1001 .with_profile_jsonl(config.profile_jsonl.clone())
1002 .with_profile_detail(config.profile_detail)
1003 .with_memory_profile_jsonl(config.memory_profile_jsonl.clone()),
1004 );
1005 let listener = tokio::net::TcpListener::bind(&addr)
1006 .await
1007 .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
1008
1009 info!("Server listening on {}", addr);
1010
1011 let lifecycle = Arc::clone(&self.lifecycle);
1012 axum::serve(listener, app)
1013 .with_graceful_shutdown(async move { lifecycle.wait_for_shutdown().await })
1014 .await
1015 .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
1016
1017 Ok(())
1018 }
1019
1020 async fn stop(&self, timeout: std::time::Duration) -> ferrum_types::Result<()> {
1021 let _stop_guard = self.lifecycle.stop_lock.lock().await;
1022 info!("Stopping Axum server");
1023 self.lifecycle.request_shutdown();
1024
1025 let mut first_error = None;
1026 if self.lifecycle.running.load(Ordering::Acquire) {
1027 if tokio::time::timeout(timeout, self.lifecycle.wait_until_stopped())
1028 .await
1029 .is_err()
1030 {
1031 first_error = Some(Error::internal(format!(
1032 "Axum server did not drain within {} ms",
1033 timeout.as_millis()
1034 )));
1035 }
1036 }
1037
1038 if !self.lifecycle.engines_stopped.load(Ordering::Acquire) {
1039 match tokio::time::timeout(timeout, self.shutdown_loaded_engines()).await {
1040 Ok(Ok(())) => {
1041 self.lifecycle
1042 .engines_stopped
1043 .store(true, Ordering::Release);
1044 }
1045 Ok(Err(error)) => {
1046 if first_error.is_none() {
1047 first_error = Some(error);
1048 }
1049 }
1050 Err(_) => {
1051 if first_error.is_none() {
1052 first_error = Some(Error::internal(format!(
1053 "engine shutdown did not complete within {} ms",
1054 timeout.as_millis()
1055 )));
1056 }
1057 }
1058 }
1059 }
1060
1061 first_error.map_or(Ok(()), Err)
1062 }
1063
1064 fn is_running(&self) -> bool {
1065 self.lifecycle.running.load(Ordering::Acquire)
1066 }
1067
1068 fn address(&self) -> Option<std::net::SocketAddr> {
1069 format!("{}:{}", self.config.host, self.config.port)
1071 .parse()
1072 .ok()
1073 }
1074
1075 fn register_handler(
1076 &mut self,
1077 _path: &str,
1078 _method: HttpMethod,
1079 _handler: Box<dyn crate::traits::RequestHandler>,
1080 ) {
1081 unimplemented!("Dynamic handler registration not implemented in MVP")
1083 }
1084
1085 fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
1086 unimplemented!("Dynamic middleware registration not implemented in MVP")
1088 }
1089
1090 fn get_metrics(&self) -> ServerMetrics {
1091 ServerMetrics {
1093 total_requests: 0,
1094 requests_by_endpoint: std::collections::HashMap::new(),
1095 requests_by_status: std::collections::HashMap::new(),
1096 avg_response_time_ms: 0.0,
1097 p95_response_time_ms: 0.0,
1098 p99_response_time_ms: 0.0,
1099 active_connections: 0,
1100 bytes_sent: 0,
1101 bytes_received: 0,
1102 error_rate: 0.0,
1103 uptime_seconds: 0,
1104 }
1105 }
1106
1107 async fn health_check(&self) -> HealthStatus {
1108 HealthStatus::Healthy
1109 }
1110}
1111
1112async fn chat_completions_handler(
1114 State(state): State<AppState>,
1115 headers: HeaderMap,
1116 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1117) -> std::result::Result<Response, ServerError> {
1118 let Json(mut request) = request.map_err(|error| {
1119 ServerError::invalid_request(
1120 format!(
1121 "invalid chat completions request: {}",
1122 json_rejection_detail(&error)
1123 ),
1124 None,
1125 )
1126 })?;
1127 let cache_policy = CachePolicy::current();
1128 let session_context =
1129 state
1130 .cache
1131 .prepare_session_request(&mut request, &headers, &cache_policy);
1132
1133 let span = span!(Level::INFO, "chat_completions", model = %request.model);
1134 let _enter = span.enter();
1135
1136 info!(
1137 "Received chat completions request for model: {}",
1138 request.model
1139 );
1140 debug!("Request: {:?}", request);
1141
1142 validate_chat_request(&request)?;
1145 let (engine_model_id, lora_adapter) = resolve_request_model(
1146 &state.served_model_registry,
1147 &request.model,
1148 ServedModelKind::Llm,
1149 )?;
1150
1151 let mut inference_request = convert_chat_request_with_template_model_and_default(
1153 &request,
1154 &engine_model_id.0,
1155 state.prompt_template.as_deref(),
1156 state.default_enable_thinking,
1157 )
1158 .map_err(server_error_from_ferrum_error)?;
1159 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
1160 if state.request_dump_dir.is_some() {
1161 inference_request.evidence_request.capture_prompt_token_ids = true;
1162 }
1163 inference_request
1164 .evidence_request
1165 .capture_engine_token_timing = state.profile_detail.captures_engine_token_timing();
1166 state
1167 .cache
1168 .record_prefix_prompt(&inference_request.prompt, &cache_policy);
1169 if let Err(err) =
1170 write_chat_request_replay_bundle(&state, &headers, &request, &inference_request)
1171 {
1172 warn!("failed to write chat request replay bundle: {}", err);
1173 }
1174
1175 if request.stream.unwrap_or(false) {
1177 handle_chat_completions_stream(state, request, inference_request).await
1178 } else {
1179 handle_chat_completions_sync(state, request, inference_request, session_context).await
1180 }
1181}
1182
1183fn json_rejection_detail(rejection: &JsonRejection) -> String {
1184 const MAX_DETAIL_CHARS: usize = 512;
1185
1186 let mut details = Vec::new();
1187 let mut current: Option<&(dyn StdError + 'static)> = Some(rejection);
1188 while let Some(error) = current {
1189 let detail = error.to_string();
1190 if !detail.is_empty() && details.last() != Some(&detail) {
1191 details.push(detail);
1192 }
1193 current = error.source();
1194 }
1195
1196 details.join(": ").chars().take(MAX_DETAIL_CHARS).collect()
1197}
1198
1199fn write_chat_request_replay_bundle(
1200 state: &AppState,
1201 headers: &HeaderMap,
1202 openai_request: &ChatCompletionsRequest,
1203 inference_request: &InferenceRequest,
1204) -> std::result::Result<(), String> {
1205 let Some(root) = state.request_dump_dir.as_ref() else {
1206 return Ok(());
1207 };
1208 let request_id = inference_request.id.to_string();
1209 let bundle_dir = root.join(&request_id);
1210 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1211
1212 let sanitized_body = sanitized_chat_request_body(openai_request);
1213 let replay_body_path = bundle_dir.join("replay_body.json");
1214 write_json_value(&replay_body_path, &sanitized_body)?;
1215 let engine_replay_argv = replay_bundle_argv(&bundle_dir);
1216 let output_text_body = format!(
1217 "[server request replay emitted before response]\nsha256={}\nchars=0\n",
1218 sha256_hex(b"")
1219 );
1220
1221 let request = serde_json::json!({
1222 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1223 "entrypoint": "serve",
1224 "request_id": request_id,
1225 "model": openai_request.model.clone(),
1226 "backend": "actual",
1227 "endpoint": "/v1/chat/completions",
1228 "method": "POST",
1229 "stream": openai_request.stream.unwrap_or(false),
1230 "actual_model_smoke": true,
1231 "sanitized": true,
1232 "http": {
1233 "method": "POST",
1234 "path": "/v1/chat/completions",
1235 "headers": sanitized_replay_headers(headers),
1236 "body": sanitized_body
1237 }
1238 });
1239 let files = [
1240 ("request.json", request),
1241 (
1242 "prompt_token_ids.json",
1243 serde_json::json!({
1244 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1245 "request_id": request_id,
1246 "model": openai_request.model.clone(),
1247 "tokenizer_or_model": openai_request.model.clone(),
1248 "token_ids": null,
1249 "token_count": null,
1250 "unavailable_reason": "server request replay captures the OpenAI body before prompt token ids are retained",
1251 "sanitized": true
1252 }),
1253 ),
1254 (
1255 "sampling_params.json",
1256 serde_json::json!({
1257 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1258 "request_id": request_id,
1259 "sampling_params": inference_request.sampling_params.clone(),
1260 "unavailable_reason": null
1261 }),
1262 ),
1263 (
1264 "runtime_effective_config.json",
1265 serde_json::json!({
1266 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1267 "request_id": request_id,
1268 "entrypoint": "serve",
1269 "endpoint": "/v1/chat/completions",
1270 "stream": openai_request.stream.unwrap_or(false),
1271 "request_dump_dir": root.to_string_lossy(),
1272 "sanitized": true
1273 }),
1274 ),
1275 (
1276 "backend_selection.json",
1277 serde_json::json!({
1278 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1279 "request_id": request_id,
1280 "backend": "actual",
1281 "model": openai_request.model.clone(),
1282 "actual_model_smoke": true
1283 }),
1284 ),
1285 (
1286 "output_token_ids.json",
1287 serde_json::json!({
1288 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1289 "request_id": request_id,
1290 "token_ids": [],
1291 "token_count": 0,
1292 "finish_reason": null,
1293 "unavailable_reason": "server request replay bundle is emitted at request admission in this WP9 slice"
1294 }),
1295 ),
1296 (
1297 "bad_output_scan.json",
1298 serde_json::json!({
1299 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1300 "request_id": request_id,
1301 "bad_output": false,
1302 "bad_text_count": 0,
1303 "reasons": [],
1304 "first_bad_text_span": null,
1305 "failure_kind": null,
1306 "output_chars": 0,
1307 "classified_output_sha256": sha256_hex(b""),
1308 "output_sha256": sha256_hex(output_text_body.as_bytes())
1309 }),
1310 ),
1311 (
1312 "replay.command.json",
1313 serde_json::json!({
1314 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1315 "request_id": request_id,
1316 "entrypoint": "serve",
1317 "command": replay_curl_command(&bundle_dir),
1318 "argv": replay_curl_argv(&bundle_dir),
1319 "bundle_dir": bundle_dir.to_string_lossy(),
1320 "requires_running_server": true,
1321 "engine_replay": {
1322 "mode": "bundle_offline",
1323 "requires_http_server": false,
1324 "command": shell_command(&engine_replay_argv),
1325 "argv": engine_replay_argv
1326 },
1327 "sanitized": true
1328 }),
1329 ),
1330 ];
1331 for (name, value) in files {
1332 write_json_value(&bundle_dir.join(name), &value)?;
1333 }
1334 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1335 .map_err(|err| err.to_string())?;
1336 Ok(())
1337}
1338
1339fn write_chat_request_failure_diagnostics(
1340 state: &AppState,
1341 request_id: &str,
1342 failure_kind: &str,
1343 phase: &str,
1344 error_kind: &str,
1345 message: &str,
1346 engine_status: Option<&EngineStatus>,
1347) -> std::result::Result<(), String> {
1348 let admission_summary = state
1349 .auto_config
1350 .as_ref()
1351 .map(|config| config.admission_summary_document());
1352 write_chat_request_failure_diagnostics_at_root(
1353 state.request_dump_dir.as_ref().map(|root| root.as_path()),
1354 admission_summary.as_ref(),
1355 engine_status,
1356 request_id,
1357 failure_kind,
1358 phase,
1359 error_kind,
1360 message,
1361 )
1362}
1363
1364fn write_chat_request_completion_replay_bundle(
1365 request_dump_dir: Option<&Path>,
1366 request_id: &str,
1367 output_text: &str,
1368 output_token_ids: &[TokenId],
1369 finish_reason: Option<&str>,
1370) -> std::result::Result<(), String> {
1371 let Some(root) = request_dump_dir else {
1372 return Ok(());
1373 };
1374 let bundle_dir = root.join(request_id);
1375 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1376 let token_ids = output_token_ids
1377 .iter()
1378 .map(|token| token.get())
1379 .collect::<Vec<_>>();
1380 let output_text_body = format!(
1381 "[redacted actual output]\nsha256={}\nchars={}\n",
1382 sha256_hex(output_text.as_bytes()),
1383 output_text.chars().count()
1384 );
1385 write_json_value(
1386 &bundle_dir.join("output_token_ids.json"),
1387 &serde_json::json!({
1388 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1389 "request_id": request_id,
1390 "token_ids": token_ids,
1391 "token_count": output_token_ids.len(),
1392 "finish_reason": finish_reason,
1393 "unavailable_reason": null
1394 }),
1395 )?;
1396 write_json_value(
1397 &bundle_dir.join("bad_output_scan.json"),
1398 &bad_output_scan_json(request_id, output_text, None, output_text_body.as_bytes()),
1399 )?;
1400 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1401 .map_err(|err| err.to_string())?;
1402 Ok(())
1403}
1404
1405fn write_chat_prompt_token_evidence(
1406 request_dump_dir: Option<&Path>,
1407 request_id: &str,
1408 model: &str,
1409 execution_evidence: Option<&InferenceExecutionEvidence>,
1410) -> std::result::Result<(), String> {
1411 let (Some(root), Some(evidence)) = (request_dump_dir, execution_evidence) else {
1412 return Ok(());
1413 };
1414 let bundle_dir = root.join(request_id);
1415 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1416 let prompt_token_ids = evidence
1417 .prompt_token_ids
1418 .iter()
1419 .map(|token| token.get())
1420 .collect::<Vec<_>>();
1421 write_json_value(
1422 &bundle_dir.join("prompt_token_ids.json"),
1423 &serde_json::json!({
1424 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1425 "request_id": request_id,
1426 "model": model,
1427 "tokenizer_or_model": model,
1428 "token_ids": prompt_token_ids,
1429 "token_count": evidence.prompt_token_ids.len(),
1430 "unavailable_reason": null,
1431 "sanitized": true
1432 }),
1433 )
1434}
1435
1436#[derive(Clone, Copy, Default)]
1437struct ChatRequestProfileTiming<'a> {
1438 engine_evidence: Option<&'a InferenceExecutionEvidence>,
1439 first_engine_chunk_received_us: Option<u64>,
1440 first_sse_enqueue_us: Option<u64>,
1441}
1442
1443#[allow(clippy::too_many_arguments)]
1444fn write_chat_request_profile_event(
1445 state: &AppState,
1446 request_id: &str,
1447 model: &str,
1448 stream: bool,
1449 phase: &str,
1450 started_at: Instant,
1451 timing: ChatRequestProfileTiming<'_>,
1452 output_token_count: usize,
1453 usage: Option<&TokenUsage>,
1454 finish_reason: Option<&str>,
1455 error: Option<ProfileError>,
1456) -> std::result::Result<(), String> {
1457 let Some(path) = state.profile_jsonl.as_ref() else {
1458 return Ok(());
1459 };
1460 let timestamp = chrono::Utc::now();
1461 let status = if error.is_some() {
1462 ProfileStatus::Failure
1463 } else {
1464 ProfileStatus::Ok
1465 };
1466 let duration_us = elapsed_us_since(started_at);
1467 let mut attributes = BTreeMap::from([
1468 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1469 (
1470 "diagnostic_only".to_string(),
1471 serde_json::json!(state.profile_detail.diagnostic_only()),
1472 ),
1473 (
1474 "endpoint".to_string(),
1475 serde_json::json!("/v1/chat/completions"),
1476 ),
1477 (
1478 "e2e_duration_us".to_string(),
1479 serde_json::json!(duration_us),
1480 ),
1481 ("l0_only".to_string(), serde_json::json!(false)),
1482 (
1483 "profile_detail".to_string(),
1484 serde_json::json!(state.profile_detail.as_str()),
1485 ),
1486 ("stream".to_string(), serde_json::json!(stream)),
1487 (
1488 "output_token_count".to_string(),
1489 serde_json::json!(output_token_count),
1490 ),
1491 (
1492 "execution_request_id".to_string(),
1493 serde_json::json!(format!("request.product.{request_id}")),
1494 ),
1495 ]);
1496 if let Some(usage) = usage {
1497 attributes.insert(
1498 "prompt_token_count".to_string(),
1499 serde_json::json!(usage.prompt_tokens),
1500 );
1501 attributes.insert(
1502 "completion_token_count".to_string(),
1503 serde_json::json!(usage.completion_tokens),
1504 );
1505 attributes.insert(
1506 "total_token_count".to_string(),
1507 serde_json::json!(usage.total_tokens),
1508 );
1509 attributes.insert("token_count_source".to_string(), serde_json::json!("usage"));
1510 } else {
1511 attributes.insert(
1512 "completion_token_count".to_string(),
1513 serde_json::json!(output_token_count),
1514 );
1515 attributes.insert(
1516 "total_token_count".to_string(),
1517 serde_json::json!(output_token_count),
1518 );
1519 attributes.insert(
1520 "token_count_source".to_string(),
1521 serde_json::json!("generated_tokens"),
1522 );
1523 }
1524 if let Some(engine_timing) = timing
1525 .engine_evidence
1526 .and_then(|evidence| evidence.engine_token_timing.as_ref())
1527 {
1528 engine_timing
1529 .validate(output_token_count)
1530 .map_err(|error| format!("invalid engine token timing evidence: {error}"))?;
1531 attributes.extend(ferrum_types::engine_token_timing_profile_attributes(
1532 engine_timing,
1533 ));
1534 } else if status == ProfileStatus::Ok && state.profile_detail.captures_engine_token_timing() {
1535 return Err(format!(
1536 "{} profile completed without required engine token timing evidence",
1537 state.profile_detail.as_str()
1538 ));
1539 }
1540 if let Some(received_us) = timing.first_engine_chunk_received_us {
1541 attributes.insert(
1542 "engine_stream_first_chunk_received_us".to_string(),
1543 serde_json::json!(received_us),
1544 );
1545 }
1546 if let Some(enqueue_us) = timing.first_sse_enqueue_us {
1547 attributes.insert(
1548 "http_first_sse_enqueue_us".to_string(),
1549 serde_json::json!(enqueue_us),
1550 );
1551 }
1552 if stream {
1553 attributes.insert(
1554 "http_stream_flush_unavailable_reason".to_string(),
1555 serde_json::json!(
1556 "socket flush completion is outside the axum handler observation boundary"
1557 ),
1558 );
1559 }
1560 if let Some(reason) = finish_reason {
1561 attributes.insert("finish_reason".to_string(), serde_json::json!(reason));
1562 }
1563 if let Some(error) = error.as_ref() {
1564 attributes.insert(
1565 if error.blocking {
1566 "first_failure_event"
1567 } else {
1568 "terminal_failure_event"
1569 }
1570 .to_string(),
1571 serde_json::json!(true),
1572 );
1573 }
1574
1575 let replay = state.request_dump_dir.as_ref().map(|root| {
1576 let bundle_dir = root.join(request_id);
1577 ReplayReference {
1578 command: replay_curl_command(&bundle_dir),
1579 bundle_dir: Some(root.to_string_lossy().to_string()),
1580 }
1581 });
1582 let resource = error.as_ref().map(|error| ResourceTraceEvent {
1583 owner_kind: "request".to_string(),
1584 owner_id: request_id.to_string(),
1585 resource_kind: "chat_request".to_string(),
1586 action: ResourceAction::Reject,
1587 amount: None,
1588 before: None,
1589 after: None,
1590 capacity: Some(1),
1591 underflow_amount: None,
1592 reason: Some(error.message.clone()),
1593 error_kind: Some(error.kind.clone()),
1594 message: Some(error.message.clone()),
1595 resource_error_kind: Some(error.kind.clone()),
1596 });
1597 let event = FerrumProfileEvent {
1598 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1599 ts_unix_nanos: timestamp
1600 .timestamp_nanos_opt()
1601 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1602 event_id: format!(
1603 "evt-server-chat-{}-{request_id}",
1604 if stream { "stream" } else { "sync" }
1605 ),
1606 request_id: request_id.to_string(),
1607 correlation_id: Some(request_id.to_string()),
1608 entrypoint: ProfileEntrypoint::Serve,
1609 backend: "actual".to_string(),
1610 runtime_preset_hash: state
1611 .auto_config
1612 .as_ref()
1613 .map(ResolvedFerrumConfig::runtime_env_hash)
1614 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1615 phase: phase.to_string(),
1616 event_kind: ProfileEventKind::TimedSpan,
1617 timestamp,
1618 status,
1619 model: Some(model.to_string()),
1620 duration_us: Some(duration_us),
1621 memory: None,
1622 resource,
1623 error,
1624 replay,
1625 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1626 backend_detail: None,
1627 attributes,
1628 };
1629 append_profile_event(path.as_path(), &event)
1630}
1631
1632fn maybe_write_first_request_memory_stage(
1633 state: &AppState,
1634 request_id: &str,
1635 model: &str,
1636 stream: bool,
1637 started_at: Instant,
1638 before: Option<ProcessMemorySample>,
1639) -> std::result::Result<(), String> {
1640 if state.profile_jsonl.is_none() && state.memory_profile_jsonl.is_none() {
1641 return Ok(());
1642 }
1643 if state
1644 .first_request_memory_recorded
1645 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1646 .is_err()
1647 {
1648 return Ok(());
1649 }
1650 let after = ProcessMemorySampler.sample();
1651 let memory = after.map(|after| ProcessMemoryObservation::from_samples(before, after));
1652 let timestamp = chrono::Utc::now();
1653 let mut attributes = BTreeMap::from([
1654 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1655 (
1656 "diagnostic_only".to_string(),
1657 serde_json::json!(state.profile_detail.diagnostic_only()),
1658 ),
1659 (
1660 "endpoint".to_string(),
1661 serde_json::json!("/v1/chat/completions"),
1662 ),
1663 ("l0_only".to_string(), serde_json::json!(false)),
1664 (
1665 "memory_stage".to_string(),
1666 serde_json::json!("first_request_done"),
1667 ),
1668 (
1669 "profile_detail".to_string(),
1670 serde_json::json!(state.profile_detail.as_str()),
1671 ),
1672 ("stream".to_string(), serde_json::json!(stream)),
1673 ]);
1674 let memory_snapshot = if let Some(memory) = &memory {
1675 attributes.insert(
1676 "memory_measurement".to_string(),
1677 serde_json::json!("process_rss"),
1678 );
1679 attributes.insert(
1680 "process_memory_source".to_string(),
1681 serde_json::json!(memory.source),
1682 );
1683 memory.to_snapshot("process", Some("actual"))
1684 } else {
1685 attributes.insert(
1686 "memory_measurement".to_string(),
1687 serde_json::json!("not_collected"),
1688 );
1689 ferrum_types::MemorySnapshot {
1690 scope: "process".to_string(),
1691 backend: Some("actual".to_string()),
1692 before_bytes: Some(0),
1693 after_bytes: Some(0),
1694 current_bytes: Some(0),
1695 high_water_bytes: Some(0),
1696 available_bytes: None,
1697 }
1698 };
1699 let replay = state.request_dump_dir.as_ref().map(|root| {
1700 let bundle_dir = root.join(request_id);
1701 ReplayReference {
1702 command: replay_curl_command(&bundle_dir),
1703 bundle_dir: Some(root.to_string_lossy().to_string()),
1704 }
1705 });
1706 let event = FerrumProfileEvent {
1707 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1708 ts_unix_nanos: timestamp
1709 .timestamp_nanos_opt()
1710 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1711 event_id: format!("evt-server-chat-memory-first-request-{request_id}"),
1712 request_id: request_id.to_string(),
1713 correlation_id: Some(request_id.to_string()),
1714 entrypoint: ProfileEntrypoint::Serve,
1715 backend: "actual".to_string(),
1716 runtime_preset_hash: state
1717 .auto_config
1718 .as_ref()
1719 .map(ResolvedFerrumConfig::runtime_env_hash)
1720 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1721 phase: "actual_serve_first_request_done".to_string(),
1722 event_kind: ProfileEventKind::Memory,
1723 timestamp,
1724 status: ProfileStatus::Ok,
1725 model: Some(model.to_string()),
1726 duration_us: Some(elapsed_us_since(started_at)),
1727 memory: Some(memory_snapshot),
1728 resource: None,
1729 error: None,
1730 replay,
1731 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1732 backend_detail: None,
1733 attributes,
1734 };
1735 if let Some(path) = &state.profile_jsonl {
1736 append_profile_event(path.as_path(), &event)?;
1737 }
1738 if let Some(path) = &state.memory_profile_jsonl {
1739 append_profile_event(path.as_path(), &event)?;
1740 }
1741 Ok(())
1742}
1743
1744fn request_memory_sample_before(state: &AppState) -> Option<ProcessMemorySample> {
1745 (state.profile_jsonl.is_some() || state.memory_profile_jsonl.is_some())
1746 .then(|| ProcessMemorySampler.sample())
1747 .flatten()
1748}
1749
1750fn append_profile_event(
1751 path: &Path,
1752 event: &FerrumProfileEvent,
1753) -> std::result::Result<(), String> {
1754 event.validate().map_err(|err| err.to_string())?;
1755 ferrum_bench_core::write_jsonl_records(
1756 path,
1757 ferrum_bench_core::JsonlJournalOpenMode::Append,
1758 std::slice::from_ref(event),
1759 )
1760 .map_err(|error| error.to_string())
1761}
1762
1763fn elapsed_us_since(started_at: Instant) -> u64 {
1764 started_at
1765 .elapsed()
1766 .as_micros()
1767 .max(1)
1768 .try_into()
1769 .unwrap_or(u64::MAX)
1770}
1771
1772fn write_chat_request_failure_diagnostics_at_root(
1773 request_dump_dir: Option<&Path>,
1774 admission_summary: Option<&serde_json::Value>,
1775 engine_status: Option<&EngineStatus>,
1776 request_id: &str,
1777 failure_kind: &str,
1778 phase: &str,
1779 error_kind: &str,
1780 message: &str,
1781) -> std::result::Result<(), String> {
1782 let Some(root) = request_dump_dir else {
1783 return Ok(());
1784 };
1785 let bundle_dir = root.join(request_id);
1786 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1787 let message = sanitize_diagnostic_text(message);
1788 let now = chrono::Utc::now();
1789
1790 let bad_scan_path = bundle_dir.join("bad_output_scan.json");
1791 let mut bad_scan = fs::read_to_string(&bad_scan_path)
1792 .ok()
1793 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
1794 .filter(|value| value.is_object())
1795 .unwrap_or_else(|| serde_json::json!({}));
1796 let bad_scan_obj = bad_scan
1797 .as_object_mut()
1798 .expect("bad scan fallback should be an object");
1799 bad_scan_obj.insert(
1800 "schema_version".to_string(),
1801 serde_json::json!(OBSERVABILITY_PROFILE_SCHEMA_VERSION),
1802 );
1803 bad_scan_obj.insert("request_id".to_string(), serde_json::json!(request_id));
1804 bad_scan_obj
1805 .entry("bad_output".to_string())
1806 .or_insert_with(|| serde_json::json!(false));
1807 bad_scan_obj
1808 .entry("bad_text_count".to_string())
1809 .or_insert_with(|| serde_json::json!(0));
1810 bad_scan_obj
1811 .entry("reasons".to_string())
1812 .or_insert_with(|| serde_json::json!([]));
1813 bad_scan_obj
1814 .entry("first_bad_text_span".to_string())
1815 .or_insert(serde_json::Value::Null);
1816 bad_scan_obj.insert("failure_kind".to_string(), serde_json::json!(failure_kind));
1817 bad_scan_obj.insert("failure_phase".to_string(), serde_json::json!(phase));
1818 bad_scan_obj.insert("error_kind".to_string(), serde_json::json!(error_kind));
1819 bad_scan_obj
1820 .entry("output_chars".to_string())
1821 .or_insert_with(|| serde_json::json!(0));
1822 bad_scan_obj
1823 .entry("output_sha256".to_string())
1824 .or_insert_with(|| serde_json::json!(sha256_hex(b"")));
1825 write_json_value(&bad_scan_path, &bad_scan)?;
1826
1827 let diagnostics = if chat_resource_failure_kind(failure_kind) {
1828 chat_resource_failure_diagnostics(
1829 request_id,
1830 failure_kind,
1831 phase,
1832 error_kind,
1833 &message,
1834 now.timestamp_millis(),
1835 admission_summary,
1836 engine_status,
1837 )
1838 } else {
1839 serde_json::json!({
1840 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1841 "entrypoint": "serve",
1842 "request_id": request_id,
1843 "failure_kind": failure_kind,
1844 "phase": phase,
1845 "first_failure_event": {
1846 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1847 "entrypoint": "serve",
1848 "request_id": request_id,
1849 "phase": phase,
1850 "error_kind": error_kind,
1851 "message": message,
1852 "timestamp_unix_ms": now.timestamp_millis()
1853 },
1854 "nearest_request_id": request_id,
1855 "log_excerpt": format!("{phase}: {message}"),
1856 "backtrace_excerpt": null,
1857 "nearest_resource_event": null,
1858 "nearest_memory_snapshot": null
1859 })
1860 };
1861 write_json_value(&bundle_dir.join("failure_diagnostics.json"), &diagnostics)?;
1862 Ok(())
1863}
1864
1865fn chat_resource_failure_diagnostics(
1866 request_id: &str,
1867 failure_kind: &str,
1868 phase: &str,
1869 error_kind: &str,
1870 message: &str,
1871 timestamp_unix_ms: i64,
1872 admission_summary: Option<&serde_json::Value>,
1873 engine_status: Option<&EngineStatus>,
1874) -> serde_json::Value {
1875 let resource_kind = chat_resource_kind_for_failure(failure_kind);
1876 let memory = engine_status
1877 .map(|status| &status.memory_usage)
1878 .map(|memory| {
1879 let current = memory.used_bytes as i64;
1880 let high_water = current.max(0);
1881 serde_json::json!({
1882 "scope": "serve_failure",
1883 "backend": "engine_status",
1884 "current_bytes": current.max(0),
1885 "high_water_bytes": high_water,
1886 "total_bytes": memory.total_bytes,
1887 "free_bytes": memory.free_bytes,
1888 "gpu_memory_bytes": memory.gpu_memory_bytes,
1889 "cpu_memory_bytes": memory.cpu_memory_bytes,
1890 "source": "engine_status"
1891 })
1892 })
1893 .unwrap_or_else(|| {
1894 serde_json::json!({
1895 "scope": "serve_failure",
1896 "backend": "engine_status",
1897 "current_bytes": 0,
1898 "high_water_bytes": 0,
1899 "source": "not_collected"
1900 })
1901 });
1902 let capacity = chat_failure_capacity(resource_kind, admission_summary, engine_status, message);
1903 let needed = capacity
1904 .get("needed")
1905 .and_then(|value| value.as_i64())
1906 .unwrap_or(1)
1907 .max(1);
1908 let capacity_value = capacity
1909 .get("capacity")
1910 .and_then(|value| value.as_i64())
1911 .unwrap_or(0)
1912 .max(0);
1913 serde_json::json!({
1914 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1915 "entrypoint": "serve",
1916 "request_id": request_id,
1917 "failure_kind": failure_kind,
1918 "phase": phase,
1919 "first_failure_event": {
1920 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1921 "entrypoint": "serve",
1922 "request_id": request_id,
1923 "phase": phase,
1924 "error_kind": error_kind,
1925 "message": message,
1926 "timestamp_unix_ms": timestamp_unix_ms
1927 },
1928 "nearest_request_id": request_id,
1929 "log_excerpt": format!("{phase}: {message}"),
1930 "capacity": capacity,
1931 "nearest_resource_event": {
1932 "owner_kind": "request",
1933 "owner_id": request_id,
1934 "resource_kind": resource_kind,
1935 "action": "reject",
1936 "amount": needed,
1937 "before": capacity_value,
1938 "after": capacity_value,
1939 "capacity": capacity_value,
1940 "reason": message
1941 },
1942 "nearest_memory_snapshot": memory
1943 })
1944}
1945
1946fn chat_failure_capacity(
1947 resource_kind: &str,
1948 admission_summary: Option<&serde_json::Value>,
1949 engine_status: Option<&EngineStatus>,
1950 reason: &str,
1951) -> serde_json::Value {
1952 if resource_kind == "device_memory" {
1953 let (needed, available, capacity) = engine_status
1954 .map(|status| {
1955 let memory = &status.memory_usage;
1956 let used = memory.used_bytes as i64;
1957 let available = memory.free_bytes as i64;
1958 let capacity = memory.total_bytes as i64;
1959 (
1960 used.saturating_add(1).max(1),
1961 available.max(0),
1962 capacity.max(0),
1963 )
1964 })
1965 .unwrap_or((1, 0, 0));
1966 return serde_json::json!({
1967 "resource_kind": resource_kind,
1968 "needed": needed,
1969 "available": available,
1970 "capacity": capacity,
1971 "reason": reason
1972 });
1973 }
1974 let capacity = admission_summary
1975 .and_then(|summary| summary.get("effective_max_concurrent"))
1976 .and_then(|value| {
1977 value
1978 .as_i64()
1979 .or_else(|| value.as_u64().map(|value| value as i64))
1980 })
1981 .unwrap_or_else(|| {
1982 engine_status
1983 .map(|status| {
1984 (status.active_requests as i64)
1985 .saturating_add(status.queued_requests as i64)
1986 .saturating_add(1)
1987 })
1988 .unwrap_or(0)
1989 })
1990 .max(0);
1991 let used = engine_status
1992 .map(|status| (status.active_requests as i64).saturating_add(status.queued_requests as i64))
1993 .unwrap_or(0)
1994 .max(0);
1995 serde_json::json!({
1996 "resource_kind": resource_kind,
1997 "needed": 1,
1998 "available": capacity.saturating_sub(used),
1999 "capacity": capacity,
2000 "reason": reason
2001 })
2002}
2003
2004fn chat_resource_failure_kind(failure_kind: &str) -> bool {
2005 matches!(
2006 failure_kind,
2007 "oom" | "prevented_oom" | "admission" | "admission_reject" | "oom_admission"
2008 )
2009}
2010
2011fn chat_resource_kind_for_failure(failure_kind: &str) -> &'static str {
2012 match failure_kind {
2013 "oom" | "prevented_oom" => "device_memory",
2014 _ => "admission_capacity",
2015 }
2016}
2017
2018fn sanitize_diagnostic_text(message: &str) -> String {
2019 let trimmed = message.trim();
2020 if trimmed.is_empty() {
2021 return "generation failed without an error message".to_string();
2022 }
2023 let lower = trimmed.to_ascii_lowercase();
2024 if lower.contains("authorization")
2025 || lower.contains("cookie")
2026 || lower.contains("api_key")
2027 || lower.contains("access_token")
2028 || lower.contains("refresh_token")
2029 || lower.contains("password")
2030 || trimmed.contains("sk-")
2031 {
2032 return "[redacted diagnostic message]".to_string();
2033 }
2034 trimmed.chars().take(2048).collect()
2035}
2036
2037fn sanitized_replay_headers(headers: &HeaderMap) -> serde_json::Value {
2038 let mut result = serde_json::Map::new();
2039 for key in ["content-type", "traceparent", "tracestate"] {
2040 if let Some(value) = headers.get(key).and_then(|value| value.to_str().ok()) {
2041 result.insert(key.to_string(), serde_json::json!(value));
2042 }
2043 }
2044 result.insert("authorization".to_string(), serde_json::json!("[redacted]"));
2045 result.insert("cookie".to_string(), serde_json::json!("[redacted]"));
2046 serde_json::Value::Object(result)
2047}
2048
2049fn sanitized_chat_request_body(request: &ChatCompletionsRequest) -> serde_json::Value {
2050 let mut value = serde_json::to_value(request).unwrap_or_else(|_| {
2051 serde_json::json!({
2052 "model": request.model.clone(),
2053 "stream": request.stream.unwrap_or(false),
2054 "messages": []
2055 })
2056 });
2057 redact_json_value(&mut value, None);
2058 value
2059}
2060
2061fn redact_json_value(value: &mut serde_json::Value, key: Option<&str>) {
2062 if key.is_some_and(is_secret_key) {
2063 *value = serde_json::json!("[redacted]");
2064 return;
2065 }
2066 if matches!(key, Some("content" | "arguments")) && value.is_string() {
2067 *value = serde_json::json!("[redacted]");
2068 return;
2069 }
2070 match value {
2071 serde_json::Value::Object(map) => {
2072 for field in ["content", "arguments"] {
2073 if let Some(chars) = map
2074 .get(field)
2075 .and_then(|child| child.as_str())
2076 .map(|text| text.chars().count())
2077 {
2078 map.insert(field.to_string(), serde_json::json!("[redacted]"));
2079 map.insert(format!("{field}_redacted"), serde_json::json!(true));
2080 map.insert(format!("{field}_chars"), serde_json::json!(chars));
2081 }
2082 }
2083 for (child_key, child) in map.iter_mut() {
2084 redact_json_value(child, Some(child_key.as_str()));
2085 }
2086 }
2087 serde_json::Value::Array(items) => {
2088 for child in items {
2089 redact_json_value(child, None);
2090 }
2091 }
2092 _ => {}
2093 }
2094}
2095
2096fn is_secret_key(key: &str) -> bool {
2097 let normalized = key
2098 .chars()
2099 .filter(|ch| *ch != '-' && *ch != '_')
2100 .flat_map(char::to_lowercase)
2101 .collect::<String>();
2102 matches!(
2103 normalized.as_str(),
2104 "authorization"
2105 | "cookie"
2106 | "secret"
2107 | "apikey"
2108 | "password"
2109 | "accesstoken"
2110 | "refreshtoken"
2111 | "idtoken"
2112 )
2113}
2114
2115fn replay_curl_argv(bundle_dir: &Path) -> Vec<String> {
2116 vec![
2117 "curl".to_string(),
2118 "-sS".to_string(),
2119 "-X".to_string(),
2120 "POST".to_string(),
2121 "http://127.0.0.1:8000/v1/chat/completions".to_string(),
2122 "-H".to_string(),
2123 "content-type: application/json".to_string(),
2124 "--data-binary".to_string(),
2125 format!("@{}", bundle_dir.join("replay_body.json").display()),
2126 ]
2127}
2128
2129fn replay_curl_command(bundle_dir: &Path) -> String {
2130 shell_command(&replay_curl_argv(bundle_dir))
2131}
2132
2133fn replay_bundle_argv(bundle_dir: &Path) -> Vec<String> {
2134 vec![
2135 "cargo".to_string(),
2136 "run".to_string(),
2137 "-p".to_string(),
2138 "ferrum-cli".to_string(),
2139 "--".to_string(),
2140 "replay-bundle".to_string(),
2141 bundle_dir.to_string_lossy().to_string(),
2142 "--out".to_string(),
2143 bundle_dir
2144 .join("engine_replay")
2145 .to_string_lossy()
2146 .to_string(),
2147 "--json".to_string(),
2148 ]
2149}
2150
2151fn shell_command(argv: &[String]) -> String {
2152 argv.iter()
2153 .map(|part| shell_quote(part))
2154 .collect::<Vec<_>>()
2155 .join(" ")
2156}
2157
2158fn shell_quote(value: &str) -> String {
2159 if value
2160 .chars()
2161 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '@'))
2162 {
2163 value.to_string()
2164 } else {
2165 format!("'{}'", value.replace('\'', "'\\''"))
2166 }
2167}
2168
2169fn write_json_value(path: &Path, value: &serde_json::Value) -> std::result::Result<(), String> {
2170 let bytes = serde_json::to_vec_pretty(value).map_err(|err| err.to_string())?;
2171 fs::write(path, [bytes, b"\n".to_vec()].concat()).map_err(|err| err.to_string())
2172}
2173
2174fn bad_output_scan_json(
2175 request_id: &str,
2176 text: &str,
2177 failure_kind: Option<&str>,
2178 output_artifact_bytes: &[u8],
2179) -> serde_json::Value {
2180 let mut reasons = Vec::new();
2181 let mut first_span: Option<serde_json::Value> = None;
2182 for (needle, reason) in [
2183 ("<unk>", "reserved_token"),
2184 ("[PAD", "reserved_token"),
2185 ("<pad>", "reserved_token"),
2186 ("<|endoftext|>", "reserved_token"),
2187 ("<|im_start|>", "reserved_token"),
2188 ("<|im_end|>", "reserved_token"),
2189 ("<|reserved_special_token", "reserved_token"),
2190 ("\u{fffd}", "invalid_utf8"),
2191 ] {
2192 if let Some(index) = text.find(needle) {
2193 reasons.push(reason);
2194 first_span.get_or_insert_with(|| {
2195 serde_json::json!({
2196 "byte_start": index,
2197 "byte_end": index + needle.len(),
2198 "text": needle,
2199 "reason": reason
2200 })
2201 });
2202 }
2203 }
2204 if let Some(index) = first_mojibake_index(text) {
2205 reasons.push("mojibake");
2206 first_span.get_or_insert_with(|| {
2207 serde_json::json!({
2208 "byte_start": index,
2209 "byte_end": index + 1,
2210 "reason": "mojibake"
2211 })
2212 });
2213 }
2214 reasons.sort_unstable();
2215 reasons.dedup();
2216 let bad_output = !reasons.is_empty();
2217 serde_json::json!({
2218 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2219 "request_id": request_id,
2220 "bad_output": bad_output,
2221 "bad_text_count": if bad_output { 1 } else { 0 },
2222 "reasons": reasons,
2223 "first_bad_text_span": first_span,
2224 "failure_kind": failure_kind,
2225 "output_chars": text.chars().count(),
2226 "classified_output_sha256": sha256_hex(text.as_bytes()),
2227 "output_sha256": sha256_hex(output_artifact_bytes)
2228 })
2229}
2230
2231fn first_mojibake_index(text: &str) -> Option<usize> {
2232 let mut chars = text.char_indices().peekable();
2233 while let Some((index, ch)) = chars.next() {
2234 match ch {
2235 '\u{00c2}' | '\u{00c3}' => {
2236 if chars.peek().is_some_and(|(_, next)| !next.is_ascii()) {
2237 return Some(index);
2238 }
2239 }
2240 '\u{00e2}' => {
2241 if chars.peek().is_some_and(|(_, next)| *next == '\u{20ac}') {
2242 return Some(index);
2243 }
2244 }
2245 _ => {}
2246 }
2247 }
2248 None
2249}
2250
2251fn sha256_hex(bytes: &[u8]) -> String {
2252 let mut hasher = Sha256::new();
2253 hasher.update(bytes);
2254 format!("{:x}", hasher.finalize())
2255}
2256
2257async fn handle_chat_completions_stream(
2259 state: AppState,
2260 openai_request: ChatCompletionsRequest,
2261 inference_request: InferenceRequest,
2262) -> std::result::Result<Response, ServerError> {
2263 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2264
2265 let engine = state.llm.clone().ok_or_else(|| {
2267 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2268 })?;
2269 let request_id = Uuid::new_v4().to_string();
2270 let include_stream_usage = openai_request
2271 .stream_options
2272 .as_ref()
2273 .and_then(|opts| opts.include_usage)
2274 .unwrap_or(false);
2275 let output_contract = EffectiveChatOutputContract::resolve(&openai_request);
2276 let buffer_json_object_stream = matches!(
2277 output_contract,
2278 EffectiveChatOutputContract::JsonObjectContent
2279 );
2280 let buffer_strict_json_schema_stream = matches!(
2281 output_contract,
2282 EffectiveChatOutputContract::StrictJsonSchemaContent
2283 );
2284 let stream_api_request = match inference_request.api_request.as_ref() {
2285 Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
2286 _ => api_chat_request(
2287 &openai_request,
2288 openai_request.tool_choice.as_ref(),
2289 ferrum_types::ApiToolCallProtocol::default(),
2290 ),
2291 };
2292 let buffer_structured_api_stream =
2293 ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
2294 let buffer_stream_output = buffer_json_object_stream
2295 || buffer_strict_json_schema_stream
2296 || buffer_structured_api_stream;
2297 let started_in_think = has_unclosed_thinking_block(&inference_request.prompt);
2300 let replay_request_id = inference_request.id.to_string();
2301 let profile_request_model = openai_request.model.clone();
2302 let profile_started_at = Instant::now();
2303 let request_memory_before = request_memory_sample_before(&state);
2304 let mut stream = match engine.infer_stream(inference_request).await {
2305 Ok(stream) => stream,
2306 Err(e) => {
2307 let failure_kind = e.observability_failure_kind();
2308 let error_kind = e.observability_error_kind();
2309 let error_message = e.to_string();
2310 if let Err(err) = write_chat_request_profile_event(
2311 &state,
2312 &replay_request_id,
2313 &profile_request_model,
2314 true,
2315 "chat_completions_stream_start",
2316 profile_started_at,
2317 ChatRequestProfileTiming::default(),
2318 0,
2319 None,
2320 Some("error"),
2321 Some(ProfileError {
2322 kind: error_kind.to_string(),
2323 message: error_message.clone(),
2324 blocking: false,
2325 }),
2326 ) {
2327 warn!("failed to write chat stream failure profile event: {}", err);
2328 }
2329 let engine_status = if chat_resource_failure_kind(failure_kind) {
2330 Some(engine.status().await)
2331 } else {
2332 None
2333 };
2334 error!(
2335 "Stream generation failed before first chunk: {}",
2336 error_message
2337 );
2338 if let Err(err) = write_chat_request_failure_diagnostics(
2339 &state,
2340 &replay_request_id,
2341 failure_kind,
2342 "chat_completions_stream_start",
2343 error_kind,
2344 &error_message,
2345 engine_status.as_ref(),
2346 ) {
2347 warn!("failed to write chat stream failure diagnostics: {}", err);
2348 }
2349 return Err(server_error_from_ferrum_error(e));
2350 }
2351 };
2352 let request_dump_dir = state.request_dump_dir.clone();
2353 let admission_summary = state
2354 .auto_config
2355 .as_ref()
2356 .map(|config| config.admission_summary_document());
2357 let diagnostics_engine = engine.clone();
2358 let profile_state = state.clone();
2359
2360 tokio::spawn(async move {
2361 let mut current_text = String::new();
2362 let mut output_token_ids = Vec::new();
2363 let mut first_engine_chunk_received_us = None;
2364 let mut first_sse_enqueue_us = None;
2365 let mut sent_reasoning_len = 0usize;
2366 let mut sent_content_len = 0usize;
2367
2368 loop {
2369 let next = tokio::select! {
2370 biased;
2371 _ = tx.closed() => break,
2372 next = stream.next() => next,
2373 };
2374 let Some(result) = next else {
2375 break;
2376 };
2377 match result {
2378 Ok(chunk) => {
2379 if first_engine_chunk_received_us.is_none()
2380 && (chunk.token.is_some() || !chunk.text.is_empty())
2381 {
2382 first_engine_chunk_received_us = Some(elapsed_us_since(profile_started_at));
2383 }
2384 if let Some(token) = chunk.token {
2385 output_token_ids.push(token);
2386 }
2387 if !chunk.text.is_empty() {
2388 current_text.push_str(&chunk.text);
2389
2390 if !buffer_stream_output {
2391 if should_defer_reasoning_stream_delta(¤t_text) {
2392 continue;
2393 }
2394 let parsed = if started_in_think {
2395 parse_reasoning_response_started_in_think(¤t_text)
2396 } else {
2397 parse_reasoning_response(¤t_text)
2398 };
2399 let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
2400 let reasoning_delta =
2401 stream_text_delta(full_reasoning, &mut sent_reasoning_len);
2402 let content_delta =
2403 stream_text_delta(&parsed.content, &mut sent_content_len);
2404 if reasoning_delta.is_empty() && content_delta.is_empty() {
2405 continue;
2406 }
2407 let response_chunk = ChatCompletionsResponse {
2409 id: request_id.clone(),
2410 object: "chat.completion.chunk".to_string(),
2411 created: chrono::Utc::now().timestamp() as u64,
2412 model: openai_request.model.clone(),
2413 choices: vec![ChatChoice {
2414 index: 0,
2415 message: None,
2416 delta: Some(ChatMessage {
2417 role: MessageRole::Assistant,
2418 content: content_delta,
2419 reasoning: (!reasoning_delta.is_empty())
2420 .then_some(reasoning_delta),
2421 name: None,
2422 tool_calls: None,
2423 tool_call_id: None,
2424 function_call: None,
2425 }),
2426 finish_reason: None,
2427 }],
2428 usage: None,
2429 };
2430
2431 let sse_event = Event::default()
2432 .json_data(&response_chunk)
2433 .unwrap_or_else(|_| Event::default().data("error"));
2434 if tx.send(Ok(sse_event)).is_err() {
2435 break;
2436 }
2437 first_sse_enqueue_us
2438 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2439 }
2440 }
2441
2442 if chunk.finish_reason.is_some() {
2443 let terminal_finish_reason = chunk
2444 .finish_reason
2445 .expect("finish_reason presence checked above");
2446 if let Err(err) = write_chat_prompt_token_evidence(
2447 request_dump_dir.as_ref().map(|root| root.as_path()),
2448 &replay_request_id,
2449 &profile_request_model,
2450 chunk.execution_evidence.as_ref(),
2451 ) {
2452 warn!("failed to write chat stream prompt-token evidence: {}", err);
2453 }
2454 let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
2455 let mut parsed_final = if started_in_think {
2456 parse_reasoning_response_started_in_think(¤t_text)
2457 } else {
2458 parse_reasoning_response(¤t_text)
2459 };
2460 parsed_final.content = normalize_structured_response_content(
2461 &openai_request,
2462 &parsed_final.content,
2463 );
2464 let structured_chat_response =
2465 finish_reason_allows_structured_api_response(terminal_finish_reason)
2466 .then(|| match chunk.api_response.as_ref() {
2467 Some(ferrum_types::ApiResponse::Chat(response)) => {
2468 Some(response.clone())
2469 }
2470 _ if buffer_structured_api_stream => {
2471 chat_api_response_from_parsed_generated_text(
2472 &stream_api_request,
2473 &parsed_final,
2474 terminal_finish_reason,
2475 )
2476 }
2477 _ => None,
2478 })
2479 .flatten();
2480
2481 if let Some(chat_response) = structured_chat_response.as_ref() {
2482 if let Err(e) =
2483 validate_structured_tool_response(&openai_request, chat_response)
2484 {
2485 let error_event = openai_error_sse_event(
2486 stream_validation_error_message(e),
2487 "internal_server_error",
2488 Some("tool_choice"),
2489 );
2490 let _ = tx.send(Ok(error_event));
2491 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2492 break;
2493 }
2494 } else if tool_choice_required(&openai_request) {
2495 log_required_tool_choice_failure(
2496 &openai_request,
2497 &parsed_final.content,
2498 parsed_final.reasoning.as_deref(),
2499 );
2500 let error_event = openai_error_sse_event(
2501 "model output did not satisfy required tool_choice",
2502 "invalid_request_error",
2503 Some("tool_choice"),
2504 );
2505 let _ = tx.send(Ok(error_event));
2506 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2507 break;
2508 }
2509 if let Err(e) = validate_hard_structured_response(
2510 &openai_request,
2511 &parsed_final.content,
2512 ) {
2513 let error_event = openai_error_sse_event(
2514 stream_validation_error_message(e),
2515 "internal_server_error",
2516 structured_response_error_param(output_contract),
2517 );
2518 let _ = tx.send(Ok(error_event));
2519 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2520 break;
2521 }
2522
2523 if let Some(chat_response) = structured_chat_response.as_ref() {
2524 let mut delta = openai_chat_delta_from_api(&chat_response.message);
2525 if delta.reasoning.is_none() {
2526 delta.reasoning = parsed_final.reasoning.clone();
2527 }
2528 let response_chunk = ChatCompletionsResponse {
2529 id: request_id.clone(),
2530 object: "chat.completion.chunk".to_string(),
2531 created: chrono::Utc::now().timestamp() as u64,
2532 model: openai_request.model.clone(),
2533 choices: vec![ChatChoice {
2534 index: 0,
2535 message: None,
2536 delta: Some(delta),
2537 finish_reason: None,
2538 }],
2539 usage: None,
2540 };
2541
2542 let sse_event = Event::default()
2543 .json_data(&response_chunk)
2544 .unwrap_or_else(|_| Event::default().data("error"));
2545 if tx.send(Ok(sse_event)).is_err() {
2546 break;
2547 }
2548 first_sse_enqueue_us
2549 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2550 } else if buffer_structured_api_stream
2551 && parsed_final.content.trim().is_empty()
2552 {
2553 let error_event = openai_error_sse_event(
2554 "model output did not satisfy tool/function call request",
2555 "internal_server_error",
2556 Some("tool_choice"),
2557 );
2558 let _ = tx.send(Ok(error_event));
2559 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2560 break;
2561 } else if buffer_stream_output && !current_text.is_empty() {
2562 let response_chunk = ChatCompletionsResponse {
2563 id: request_id.clone(),
2564 object: "chat.completion.chunk".to_string(),
2565 created: chrono::Utc::now().timestamp() as u64,
2566 model: openai_request.model.clone(),
2567 choices: vec![ChatChoice {
2568 index: 0,
2569 message: None,
2570 delta: Some(ChatMessage {
2571 role: MessageRole::Assistant,
2572 content: parsed_final.content.clone(),
2573 reasoning: parsed_final.reasoning.clone(),
2574 name: None,
2575 tool_calls: None,
2576 tool_call_id: None,
2577 function_call: None,
2578 }),
2579 finish_reason: None,
2580 }],
2581 usage: None,
2582 };
2583
2584 let sse_event = Event::default()
2585 .json_data(&response_chunk)
2586 .unwrap_or_else(|_| Event::default().data("error"));
2587 if tx.send(Ok(sse_event)).is_err() {
2588 break;
2589 }
2590 first_sse_enqueue_us
2591 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2592 }
2593 let final_finish_reason = structured_chat_response
2603 .as_ref()
2604 .and_then(|response| response.finish_reason.clone())
2605 .or_else(|| chunk.finish_reason.as_ref().map(finish_reason_to_string))
2606 .or(Some("length".to_string()));
2607 let final_chunk = ChatCompletionsResponse {
2608 id: request_id.clone(),
2609 object: "chat.completion.chunk".to_string(),
2610 created: chrono::Utc::now().timestamp() as u64,
2611 model: openai_request.model.clone(),
2612 choices: vec![ChatChoice {
2613 index: 0,
2614 message: None,
2615 delta: Some(ChatMessage {
2616 role: MessageRole::Assistant,
2617 content: String::new(),
2618 reasoning: None,
2619 name: None,
2620 tool_calls: None,
2621 tool_call_id: None,
2622 function_call: None,
2623 }),
2624 finish_reason: final_finish_reason.clone(),
2625 }],
2626 usage: None,
2627 };
2628
2629 let final_event = Event::default()
2630 .json_data(&final_chunk)
2631 .unwrap_or_else(|_| Event::default().data("error"));
2632 if tx.send(Ok(final_event)).is_ok() {
2633 first_sse_enqueue_us
2634 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2635 }
2636 let completion_token_count = chunk
2637 .usage
2638 .as_ref()
2639 .map(|usage| usage.completion_tokens)
2640 .unwrap_or(output_token_ids.len());
2641 let replay_output_token_ids = chunk
2642 .execution_evidence
2643 .as_ref()
2644 .map(|evidence| evidence.output_token_ids.as_slice())
2645 .filter(|tokens| tokens.len() == completion_token_count)
2646 .unwrap_or(output_token_ids.as_slice());
2647 if let Err(err) = write_chat_request_completion_replay_bundle(
2648 request_dump_dir.as_ref().map(|root| root.as_path()),
2649 &replay_request_id,
2650 &parsed_final.content,
2651 replay_output_token_ids,
2652 final_finish_reason.as_deref(),
2653 ) {
2654 warn!("failed to write chat stream replay bundle: {}", err);
2655 }
2656 if let Err(err) = write_chat_request_profile_event(
2657 &profile_state,
2658 &replay_request_id,
2659 &profile_request_model,
2660 true,
2661 "chat_completions_stream_complete",
2662 profile_started_at,
2663 ChatRequestProfileTiming {
2664 engine_evidence: chunk.execution_evidence.as_ref(),
2665 first_engine_chunk_received_us,
2666 first_sse_enqueue_us,
2667 },
2668 completion_token_count,
2669 chunk.usage.as_ref(),
2670 final_finish_reason.as_deref(),
2671 None,
2672 ) {
2673 warn!("failed to write chat stream profile event: {}", err);
2674 }
2675 if let Err(err) = maybe_write_first_request_memory_stage(
2676 &profile_state,
2677 &replay_request_id,
2678 &profile_request_model,
2679 true,
2680 profile_started_at,
2681 request_memory_before,
2682 ) {
2683 warn!("failed to write chat stream memory profile event: {}", err);
2684 }
2685 if include_stream_usage && usage.is_some() {
2686 let usage_chunk = ChatCompletionsResponse {
2687 id: request_id.clone(),
2688 object: "chat.completion.chunk".to_string(),
2689 created: chrono::Utc::now().timestamp() as u64,
2690 model: openai_request.model.clone(),
2691 choices: vec![],
2692 usage,
2693 };
2694 let usage_event = Event::default()
2695 .json_data(&usage_chunk)
2696 .unwrap_or_else(|_| Event::default().data("error"));
2697 let _ = tx.send(Ok(usage_event));
2698 }
2699 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2700 break;
2701 }
2702 }
2703 Err(e) => {
2704 let failure_kind = e.observability_failure_kind();
2705 let error_kind = e.observability_error_kind();
2706 let error_message = e.to_string();
2707 let engine_status = if chat_resource_failure_kind(failure_kind) {
2708 Some(diagnostics_engine.status().await)
2709 } else {
2710 None
2711 };
2712 error!("Stream generation error: {}", error_message);
2713 if let Err(err) = write_chat_request_profile_event(
2714 &profile_state,
2715 &replay_request_id,
2716 &profile_request_model,
2717 true,
2718 "chat_completions_stream_next",
2719 profile_started_at,
2720 ChatRequestProfileTiming {
2721 engine_evidence: None,
2722 first_engine_chunk_received_us,
2723 first_sse_enqueue_us,
2724 },
2725 output_token_ids.len(),
2726 None,
2727 Some("error"),
2728 Some(ProfileError {
2729 kind: error_kind.to_string(),
2730 message: error_message.clone(),
2731 blocking: false,
2732 }),
2733 ) {
2734 warn!("failed to write chat stream chunk profile event: {}", err);
2735 }
2736 if let Err(err) = write_chat_request_failure_diagnostics_at_root(
2737 request_dump_dir.as_ref().map(|root| root.as_path()),
2738 admission_summary.as_ref(),
2739 engine_status.as_ref(),
2740 &replay_request_id,
2741 failure_kind,
2742 "chat_completions_stream_next",
2743 error_kind,
2744 &error_message,
2745 ) {
2746 warn!(
2747 "failed to write chat stream chunk failure diagnostics: {}",
2748 err
2749 );
2750 }
2751 let _ = tx.send(Ok(openai_error_sse_event(
2752 error_message,
2753 "internal_server_error",
2754 None,
2755 )));
2756 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2757 break;
2758 }
2759 }
2760 }
2761 });
2762
2763 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
2764 let sse_stream = Sse::new(stream);
2765
2766 Ok(sse_stream.into_response())
2767}
2768
2769async fn handle_chat_completions_sync(
2771 state: AppState,
2772 openai_request: ChatCompletionsRequest,
2773 inference_request: InferenceRequest,
2774 session_context: Option<SessionContext>,
2775) -> std::result::Result<Response, ServerError> {
2776 info!("Processing non-streaming chat completion");
2777
2778 let engine = state.llm.clone().ok_or_else(|| {
2779 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2780 })?;
2781 let request_chat_api = inference_request
2782 .api_request
2783 .as_ref()
2784 .and_then(|api_request| match api_request {
2785 ferrum_types::ApiRequest::Chat(chat_request) => {
2786 ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
2787 .then(|| chat_request.clone())
2788 }
2789 _ => None,
2790 });
2791 let started_in_think = has_unclosed_thinking_block(&inference_request.prompt);
2793 let replay_request_id = inference_request.id.to_string();
2794 let profile_request_model = openai_request.model.clone();
2795 let profile_started_at = Instant::now();
2796 let request_memory_before = request_memory_sample_before(&state);
2797 match engine.infer(inference_request).await {
2798 Ok(output) => {
2799 let InferenceResponse {
2800 text: output_text,
2801 tokens,
2802 finish_reason,
2803 usage,
2804 api_response,
2805 execution_evidence,
2806 ..
2807 } = output;
2808 if let Err(err) = write_chat_prompt_token_evidence(
2809 state.request_dump_dir.as_ref().map(|root| root.as_path()),
2810 &replay_request_id,
2811 &profile_request_model,
2812 execution_evidence.as_ref(),
2813 ) {
2814 warn!("failed to write chat prompt-token evidence: {}", err);
2815 }
2816
2817 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
2821 let content = strip_after_stop(&output_text, &stop_sequences);
2822 let parsed = if started_in_think {
2823 parse_reasoning_response_started_in_think(&content)
2824 } else {
2825 parse_reasoning_response(&content)
2826 };
2827 let visible_content =
2828 normalize_structured_response_content(&openai_request, &parsed.content);
2829 let mut message = ChatMessage {
2830 role: MessageRole::Assistant,
2831 content: visible_content,
2832 reasoning: parsed.reasoning.clone(),
2833 name: None,
2834 tool_calls: None,
2835 tool_call_id: None,
2836 function_call: None,
2837 };
2838 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
2839 let structured_chat_response =
2840 finish_reason_allows_structured_api_response(finish_reason)
2841 .then(|| match api_response.as_ref() {
2842 Some(ferrum_types::ApiResponse::Chat(chat_response)) => {
2843 Some(chat_response.clone())
2844 }
2845 _ => match request_chat_api.as_ref() {
2846 Some(chat_request) => chat_api_response_from_parsed_generated_text(
2847 chat_request,
2848 &parsed,
2849 finish_reason,
2850 ),
2851 _ => None,
2852 },
2853 })
2854 .flatten();
2855 if let Some(chat_response) = structured_chat_response.as_ref() {
2856 if let Err(error) =
2857 validate_structured_tool_response(&openai_request, chat_response)
2858 {
2859 if let Err(err) = write_chat_request_profile_event(
2860 &state,
2861 &replay_request_id,
2862 &profile_request_model,
2863 false,
2864 "chat_completions_sync_tool_contract",
2865 profile_started_at,
2866 ChatRequestProfileTiming {
2867 engine_evidence: execution_evidence.as_ref(),
2868 ..Default::default()
2869 },
2870 tokens.len(),
2871 Some(&usage),
2872 Some("error"),
2873 Some(ProfileError {
2874 kind: "tool_contract_failure".to_string(),
2875 message: format!("{error:?}"),
2876 blocking: true,
2877 }),
2878 ) {
2879 warn!("failed to write chat tool-contract profile event: {}", err);
2880 }
2881 return Err(error);
2882 }
2883 message = openai_chat_message_from_api(&chat_response.message);
2884 if message.reasoning.is_none() {
2885 message.reasoning = parsed.reasoning.clone();
2886 }
2887 if let Some(reason) = &chat_response.finish_reason {
2888 openai_finish_reason = reason.clone();
2889 }
2890 } else if tool_choice_required(&openai_request) {
2891 log_required_tool_choice_failure(
2892 &openai_request,
2893 &parsed.content,
2894 parsed.reasoning.as_deref(),
2895 );
2896 if let Err(err) = write_chat_request_profile_event(
2897 &state,
2898 &replay_request_id,
2899 &profile_request_model,
2900 false,
2901 "chat_completions_sync_tool_choice",
2902 profile_started_at,
2903 ChatRequestProfileTiming {
2904 engine_evidence: execution_evidence.as_ref(),
2905 ..Default::default()
2906 },
2907 tokens.len(),
2908 Some(&usage),
2909 Some("error"),
2910 Some(ProfileError {
2911 kind: "required_tool_failure".to_string(),
2912 message: "model output did not satisfy required tool_choice".to_string(),
2913 blocking: true,
2914 }),
2915 ) {
2916 warn!("failed to write chat tool-choice profile event: {}", err);
2917 }
2918 return Err(ServerError::invalid_request(
2919 "model output did not satisfy required tool_choice",
2920 Some("tool_choice"),
2921 ));
2922 }
2923 if let Err(error) = validate_hard_structured_response(&openai_request, &message.content)
2924 {
2925 if let Err(err) = write_chat_request_profile_event(
2926 &state,
2927 &replay_request_id,
2928 &profile_request_model,
2929 false,
2930 "chat_completions_sync_structured_output",
2931 profile_started_at,
2932 ChatRequestProfileTiming {
2933 engine_evidence: execution_evidence.as_ref(),
2934 ..Default::default()
2935 },
2936 tokens.len(),
2937 Some(&usage),
2938 Some("error"),
2939 Some(ProfileError {
2940 kind: "structured_output_failure".to_string(),
2941 message: format!("{error:?}"),
2942 blocking: true,
2943 }),
2944 ) {
2945 warn!("failed to write chat strict-schema profile event: {}", err);
2946 }
2947 return Err(error);
2948 }
2949 if let Err(err) = write_chat_request_completion_replay_bundle(
2950 state.request_dump_dir.as_ref().map(|root| root.as_path()),
2951 &replay_request_id,
2952 &message.content,
2953 &tokens,
2954 Some(&openai_finish_reason),
2955 ) {
2956 warn!("failed to write chat completion replay bundle: {}", err);
2957 }
2958 if let Err(err) = write_chat_request_profile_event(
2959 &state,
2960 &replay_request_id,
2961 &profile_request_model,
2962 false,
2963 "chat_completions_sync_complete",
2964 profile_started_at,
2965 ChatRequestProfileTiming {
2966 engine_evidence: execution_evidence.as_ref(),
2967 ..Default::default()
2968 },
2969 tokens.len(),
2970 Some(&usage),
2971 Some(&openai_finish_reason),
2972 None,
2973 ) {
2974 warn!("failed to write chat sync profile event: {}", err);
2975 }
2976 if let Err(err) = maybe_write_first_request_memory_stage(
2977 &state,
2978 &replay_request_id,
2979 &profile_request_model,
2980 false,
2981 profile_started_at,
2982 request_memory_before,
2983 ) {
2984 warn!("failed to write chat sync memory profile event: {}", err);
2985 }
2986 state
2987 .cache
2988 .update_session(session_context, message.clone(), &CachePolicy::current());
2989 let response = ChatCompletionsResponse {
2990 id: Uuid::new_v4().to_string(),
2991 object: "chat.completion".to_string(),
2992 created: chrono::Utc::now().timestamp() as u64,
2993 model: openai_request.model,
2994 choices: vec![ChatChoice {
2995 index: 0,
2996 message: Some(message),
2997 delta: None,
2998 finish_reason: Some(openai_finish_reason),
2999 }],
3000 usage: Some(openai_usage_from_token_usage(&usage)),
3001 };
3002
3003 Ok(Json(response).into_response())
3004 }
3005 Err(e) => {
3006 let failure_kind = e.observability_failure_kind();
3007 let error_kind = e.observability_error_kind();
3008 let error_message = e.to_string();
3009 let engine_status = if chat_resource_failure_kind(failure_kind) {
3010 Some(engine.status().await)
3011 } else {
3012 None
3013 };
3014 error!("Generation failed: {}", error_message);
3015 if let Err(err) = write_chat_request_profile_event(
3016 &state,
3017 &replay_request_id,
3018 &profile_request_model,
3019 false,
3020 "chat_completions_sync",
3021 profile_started_at,
3022 ChatRequestProfileTiming::default(),
3023 0,
3024 None,
3025 Some("error"),
3026 Some(ProfileError {
3027 kind: error_kind.to_string(),
3028 message: error_message.clone(),
3029 blocking: false,
3030 }),
3031 ) {
3032 warn!("failed to write chat sync failure profile event: {}", err);
3033 }
3034 if let Err(err) = write_chat_request_failure_diagnostics(
3035 &state,
3036 &replay_request_id,
3037 failure_kind,
3038 "chat_completions_sync",
3039 error_kind,
3040 &error_message,
3041 engine_status.as_ref(),
3042 ) {
3043 warn!(
3044 "failed to write chat generation failure diagnostics: {}",
3045 err
3046 );
3047 }
3048 Err(server_error_from_ferrum_error(e))
3049 }
3050 }
3051}
3052
3053#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3054enum EffectiveChatOutputContract {
3055 RequiredToolCall,
3056 StrictJsonSchemaContent,
3057 JsonObjectContent,
3058 BestEffortJsonSchemaContent,
3059 Text,
3060}
3061
3062#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3063enum ChatOutputBudget {
3064 AutoCeiling(u32),
3065 Explicit(u32),
3066}
3067
3068impl ChatOutputBudget {
3069 fn resolve(request: &ChatCompletionsRequest) -> Self {
3070 request
3071 .max_completion_tokens
3072 .or(request.max_tokens)
3073 .map(Self::Explicit)
3074 .unwrap_or(Self::AutoCeiling(DEFAULT_COMPLETION_MAX_TOKENS))
3075 }
3076
3077 const fn ceiling(self) -> u32 {
3078 match self {
3079 Self::AutoCeiling(value) | Self::Explicit(value) => value,
3080 }
3081 }
3082
3083 const fn is_auto(self) -> bool {
3084 matches!(self, Self::AutoCeiling(_))
3085 }
3086}
3087
3088impl EffectiveChatOutputContract {
3089 fn resolve(request: &ChatCompletionsRequest) -> Self {
3090 if tool_choice_required(request) {
3091 return Self::RequiredToolCall;
3092 }
3093 let Some(format) = request.response_format.as_ref() else {
3094 return Self::Text;
3095 };
3096 match format.format_type.as_str() {
3097 "json_schema"
3098 if format
3099 .json_schema
3100 .as_ref()
3101 .and_then(|schema| schema.strict)
3102 .unwrap_or(false) =>
3103 {
3104 Self::StrictJsonSchemaContent
3105 }
3106 "json_schema" => Self::BestEffortJsonSchemaContent,
3107 "json_object" => Self::JsonObjectContent,
3108 _ => Self::Text,
3109 }
3110 }
3111
3112 fn accepts_requested_response_format(self) -> bool {
3113 !matches!(self, Self::RequiredToolCall)
3114 }
3115}
3116
3117#[allow(dead_code)]
3119fn convert_chat_request(
3120 request: &ChatCompletionsRequest,
3121) -> ferrum_types::Result<InferenceRequest> {
3122 convert_chat_request_with_template_model(request, &request.model, None)
3123}
3124
3125fn convert_chat_request_with_template_model(
3132 request: &ChatCompletionsRequest,
3133 template_model_id: &str,
3134 model_template: Option<&ModelChatTemplate>,
3135) -> ferrum_types::Result<InferenceRequest> {
3136 convert_chat_request_with_template_model_and_default(
3137 request,
3138 template_model_id,
3139 model_template,
3140 None,
3141 )
3142}
3143
3144fn convert_chat_request_with_template_model_and_default(
3145 request: &ChatCompletionsRequest,
3146 template_model_id: &str,
3147 model_template: Option<&ModelChatTemplate>,
3148 default_enable_thinking: Option<bool>,
3149) -> ferrum_types::Result<InferenceRequest> {
3150 let no_tools: &[ChatTool] = &[];
3151 let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
3152 no_tools
3153 } else {
3154 request.tools.as_deref().unwrap_or_default()
3155 };
3156 let default_tool_choice =
3157 default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
3158 let effective_tool_choice = request
3159 .tool_choice
3160 .as_ref()
3161 .or(default_tool_choice.as_ref());
3162 let functions = request.functions.as_deref().unwrap_or_default();
3163 let output_contract = EffectiveChatOutputContract::resolve(request);
3164 let output_budget = ChatOutputBudget::resolve(request);
3165 let forced_response_format = forced_tool_choice_response_format(request);
3166 let hard_tool_call_contract = forced_response_format.is_some();
3167 let requested_response_format = output_contract
3168 .accepts_requested_response_format()
3169 .then(|| requested_response_format_for_sampling(request))
3170 .transpose()?
3171 .flatten();
3172 let chat_template_options =
3173 chat_template_options_for_request(request, model_template, default_enable_thinking)?;
3174 let response_format = forced_response_format
3175 .or(requested_response_format)
3176 .unwrap_or(ferrum_types::ResponseFormat::Text);
3177 let model_generated_thinking = model_template.is_some_and(|template| {
3178 template.reasoning_protocol == ModelReasoningProtocol::ModelGenerated
3179 && template.reasoning_enabled(chat_template_options.enable_thinking)
3180 });
3181 let reasoning_enabled = model_template
3182 .is_some_and(|template| template.reasoning_enabled(chat_template_options.enable_thinking));
3183 let render_messages = render_messages_with_response_format_instruction(
3184 request,
3185 output_contract,
3186 reasoning_enabled,
3187 );
3188 let prompt = if tools.is_empty() && functions.is_empty() {
3189 render_chat_prompt_with_model_template_options(
3190 &render_messages,
3191 template_model_id,
3192 model_template,
3193 &chat_template_options,
3194 )?
3195 } else {
3196 render_chat_prompt_with_tools_and_model_template(
3197 &render_messages,
3198 template_model_id,
3199 model_template,
3200 &chat_template_options,
3201 tools,
3202 effective_tool_choice,
3203 functions,
3204 request.function_call.as_ref(),
3205 )?
3206 };
3207 let tool_call_protocol = model_template
3208 .map(|template| template.tool_call_protocol)
3209 .unwrap_or_default();
3210 let api_chat = api_chat_request(request, effective_tool_choice, tool_call_protocol);
3211 let mut metadata = HashMap::new();
3212 metadata.insert(
3213 "openai_messages".to_string(),
3214 serde_json::to_value(&request.messages)?,
3215 );
3216 if let Some(tools) = &request.tools {
3217 metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
3218 }
3219 if let Some(tool_choice) = effective_tool_choice {
3220 metadata.insert(
3221 "openai_tool_choice".to_string(),
3222 serde_json::to_value(tool_choice)?,
3223 );
3224 }
3225 if let Some(functions) = &request.functions {
3226 metadata.insert(
3227 "openai_legacy_functions".to_string(),
3228 serde_json::to_value(functions)?,
3229 );
3230 }
3231 if let Some(function_call) = &request.function_call {
3232 metadata.insert(
3233 "openai_legacy_function_call".to_string(),
3234 serde_json::to_value(function_call)?,
3235 );
3236 }
3237 if request.ignore_eos.unwrap_or(false) {
3238 metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
3239 }
3240 if output_budget.is_auto() {
3241 metadata.insert(
3242 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
3243 serde_json::json!(true),
3244 );
3245 }
3246 if !has_unclosed_thinking_block(&prompt) {
3247 let mut forbidden = vec![THINK_END_TAG.to_string()];
3248 if hard_tool_call_contract {
3249 for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
3250 push_unique_forbidden_token_text(&mut forbidden, token_text);
3251 }
3252 if let Some(eos) = model_template.as_ref().and_then(|template| {
3253 template
3254 .eos_token
3255 .as_deref()
3256 .filter(|token| !token.is_empty())
3257 }) {
3258 push_unique_forbidden_token_text(&mut forbidden, eos);
3259 }
3260 }
3261 if chat_template_options.enable_thinking == Some(false) {
3262 push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
3263 }
3264 metadata.insert(
3265 INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
3266 serde_json::json!(forbidden),
3267 );
3268 }
3269 let prompt_opened_thinking = has_unclosed_thinking_block(&prompt);
3270 let structured_output_after_reasoning =
3271 !matches!(response_format, ferrum_types::ResponseFormat::Text)
3272 && (prompt_opened_thinking || model_generated_thinking);
3273 let structured_output_start = if structured_output_after_reasoning {
3274 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
3275 } else {
3276 StructuredOutputStart::Immediate
3277 };
3278 let response_completion_boundary =
3279 if prompt_opened_thinking || structured_output_after_reasoning {
3280 ResponseCompletionBoundary::AfterDelimiterAndPayload {
3281 delimiter: THINK_END_TAG.to_string(),
3282 alternate_envelope: api_chat.generated_response_envelope(),
3283 }
3284 } else {
3285 ResponseCompletionBoundary::Immediate
3286 };
3287
3288 Ok(InferenceRequest {
3289 id: RequestId(Uuid::new_v4()),
3290 model_id: ModelId(request.model.clone()),
3291 prompt,
3292 sampling_params: SamplingParams {
3293 max_tokens: output_budget.ceiling() as usize,
3294 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
3295 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
3296 top_k: request
3297 .top_k
3298 .filter(|value| *value > 0)
3299 .and_then(|value| usize::try_from(value).ok()),
3300 repetition_penalty: request
3301 .repetition_penalty
3302 .unwrap_or(DEFAULT_CHAT_REPETITION_PENALTY),
3303 presence_penalty: request.presence_penalty.unwrap_or(0.0),
3304 frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
3305 stop_sequences: request.stop.clone().unwrap_or_default(),
3306 seed: request.seed,
3307 min_p: request.min_p.filter(|value| *value > 0.0),
3308 tfs: None,
3309 typical_p: None,
3310 mirostat: None,
3311 response_format,
3312 structured_output_start,
3313 response_completion_boundary,
3314 },
3315 stream: request.stream.unwrap_or(false),
3316 priority: Priority::Normal, client_id: None,
3318 session_id: None,
3319 created_at: chrono::Utc::now(),
3320 api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
3321 evidence_request: Default::default(),
3322 metadata,
3323 })
3324}
3325
3326fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
3327 if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
3328 tokens.push(token.to_string());
3329 }
3330}
3331
3332fn default_auto_tool_choice_for_tools(
3333 tools: &[ChatTool],
3334 choice: Option<&ToolChoice>,
3335) -> Option<ToolChoice> {
3336 if choice.is_none() && !tools.is_empty() {
3337 Some(ToolChoice::Mode("auto".to_string()))
3338 } else {
3339 None
3340 }
3341}
3342
3343fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
3344 matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
3345}
3346
3347fn tool_choice_none_hides_tools(
3348 choice: Option<&ToolChoice>,
3349 model_template: Option<&ModelChatTemplate>,
3350) -> bool {
3351 tool_choice_none(choice)
3352 && model_template
3353 .map(|template| template.template.contains("tools_in_user_message"))
3354 .unwrap_or(false)
3355}
3356
3357fn chat_template_options_for_request(
3358 request: &ChatCompletionsRequest,
3359 model_template: Option<&ModelChatTemplate>,
3360 default_enable_thinking: Option<bool>,
3361) -> ferrum_types::Result<ChatTemplateOptions> {
3362 let mut options = ChatTemplateOptions::default_for_template(model_template);
3363 options.enable_thinking = default_enable_thinking;
3364 let Some(kwargs) = request.chat_template_kwargs.as_ref() else {
3365 return Ok(options);
3366 };
3367 let Some(value) = kwargs.get("enable_thinking") else {
3368 return Ok(options);
3369 };
3370 let Some(enable_thinking) = value.as_bool() else {
3371 return Err(Error::invalid_request(
3372 "chat_template_kwargs.enable_thinking must be a boolean",
3373 ));
3374 };
3375 options.enable_thinking = Some(enable_thinking);
3376 Ok(options)
3377}
3378
3379fn render_messages_with_response_format_instruction(
3380 request: &ChatCompletionsRequest,
3381 output_contract: EffectiveChatOutputContract,
3382 reasoning_enabled: bool,
3383) -> Vec<ChatMessage> {
3384 let Some(instruction) =
3385 response_format_prompt_instruction(request, output_contract, reasoning_enabled)
3386 else {
3387 return request.messages.clone();
3388 };
3389 let mut messages = Vec::with_capacity(request.messages.len() + 1);
3390 messages.push(ChatMessage {
3391 role: MessageRole::System,
3392 content: instruction,
3393 reasoning: None,
3394 name: None,
3395 tool_calls: None,
3396 tool_call_id: None,
3397 function_call: None,
3398 });
3399 messages.extend(request.messages.clone());
3400 messages
3401}
3402
3403fn response_format_prompt_instruction(
3404 request: &ChatCompletionsRequest,
3405 output_contract: EffectiveChatOutputContract,
3406 reasoning_enabled: bool,
3407) -> Option<String> {
3408 if !output_contract.accepts_requested_response_format() {
3409 return None;
3410 }
3411 if let Some(format) = request.response_format.as_ref() {
3412 return match format.format_type.as_str() {
3413 "json_object" => Some(
3414 "The response_format requires a single valid JSON object. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text."
3415 .to_string(),
3416 ),
3417 "json_schema" => {
3418 let schema = format.json_schema.as_ref()?.schema.as_ref()?;
3419 let schema_text = serde_json::to_string(schema).ok()?;
3420 Some(if reasoning_enabled {
3421 format!(
3422 "The response_format requires a single valid JSON value satisfying this JSON Schema. Complete any enabled reasoning before the final answer. In the final answer, output only JSON, with no markdown fences, no explanation, and no extra text. Schema: {schema_text}"
3423 )
3424 } else {
3425 format!(
3426 "The response_format requires a single valid JSON value satisfying this JSON Schema. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text. Schema: {schema_text}"
3427 )
3428 })
3429 }
3430 _ => None,
3431 };
3432 }
3433 None
3434}
3435
3436fn forced_tool_choice_response_format(
3437 request: &ChatCompletionsRequest,
3438) -> Option<ferrum_types::ResponseFormat> {
3439 let selected_tool = selected_tool_for_forced_tool_choice(request)?;
3440 let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
3441 serde_json::to_string(&schema)
3442 .ok()
3443 .map(ferrum_types::ResponseFormat::JsonSchema)
3444}
3445
3446fn requested_response_format_for_sampling(
3447 request: &ChatCompletionsRequest,
3448) -> ferrum_types::Result<Option<ferrum_types::ResponseFormat>> {
3449 let Some(format) = request.response_format.as_ref() else {
3450 return Ok(None);
3451 };
3452 match format.format_type.as_str() {
3453 "json_object" => Ok(Some(ferrum_types::ResponseFormat::JsonObject)),
3454 "json_schema" => {
3455 let Some(schema) = format.json_schema.as_ref() else {
3456 return Err(Error::invalid_request(
3457 "response_format.json_schema.schema is required",
3458 ));
3459 };
3460 if !schema.strict.unwrap_or(false) {
3461 return Ok(None);
3462 }
3463 let Some(schema_value) = schema.schema.as_ref() else {
3464 return Err(Error::invalid_request(
3465 "response_format.json_schema.schema is required",
3466 ));
3467 };
3468 serde_json::to_string(schema_value)
3469 .map(|schema| Some(ferrum_types::ResponseFormat::JsonSchema(schema)))
3470 .map_err(|err| Error::invalid_request(err.to_string()))
3471 }
3472 _ => Ok(None),
3473 }
3474}
3475
3476fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
3477 match request.tool_choice.as_ref()? {
3478 ToolChoice::Function {
3479 tool_type,
3480 function,
3481 } if tool_type == "function" => request
3482 .tools
3483 .as_ref()?
3484 .iter()
3485 .find(|tool| tool.function.name == function.name),
3486 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
3487 single_function_tool(request.tools.as_deref()?)
3488 }
3489 _ => None,
3490 }
3491}
3492
3493fn guided_tool_arguments_schema(
3494 parameters: Option<&serde_json::Value>,
3495) -> Option<serde_json::Value> {
3496 let mut schema = parameters?.clone();
3497 bound_unconstrained_tool_argument_strings(
3498 &mut schema,
3499 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
3500 );
3501 Some(schema)
3502}
3503
3504fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
3505 match value {
3506 serde_json::Value::Object(map) => {
3507 let is_string = map
3508 .get("type")
3509 .and_then(serde_json::Value::as_str)
3510 .is_some_and(|ty| ty == "string");
3511 let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
3512 if is_string && !has_finite_string_shape {
3513 map.insert(
3514 "maxLength".to_string(),
3515 serde_json::Value::Number(default_max.into()),
3516 );
3517 }
3518 if let Some(properties) = map
3519 .get_mut("properties")
3520 .and_then(serde_json::Value::as_object_mut)
3521 {
3522 for property in properties.values_mut() {
3523 bound_unconstrained_tool_argument_strings(property, default_max);
3524 }
3525 }
3526 if let Some(items) = map.get_mut("items") {
3527 bound_unconstrained_tool_argument_strings(items, default_max);
3528 }
3529 }
3530 serde_json::Value::Array(items) => {
3531 for item in items {
3532 bound_unconstrained_tool_argument_strings(item, default_max);
3533 }
3534 }
3535 _ => {}
3536 }
3537}
3538
3539fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
3540 let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
3541 let tool = function_tools.next()?;
3542 function_tools.next().is_none().then_some(tool)
3543}
3544
3545fn should_defer_reasoning_stream_delta(text: &str) -> bool {
3546 let candidate = text.trim_start_matches(['\r', '\n']);
3547 if candidate.is_empty() {
3548 return true;
3549 }
3550 THINK_START_TAG.starts_with(candidate) || THINK_END_TAG.starts_with(candidate)
3551}
3552
3553fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
3554 if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
3555 let delta = text[*sent_len..].to_string();
3556 *sent_len = text.len();
3557 return delta;
3558 }
3559 *sent_len = text.len();
3560 String::new()
3561}
3562
3563fn chat_api_response_from_parsed_generated_text(
3564 chat_request: &ferrum_types::ApiChatRequest,
3565 parsed: &ParsedReasoningResponse,
3566 finish_reason: FinishReason,
3567) -> Option<ferrum_types::ApiChatResponse> {
3568 parsed
3569 .reasoning
3570 .as_deref()
3571 .and_then(|reasoning| {
3572 ferrum_types::chat_api_response_from_generated_text(
3573 chat_request,
3574 reasoning,
3575 finish_reason,
3576 )
3577 })
3578 .or_else(|| {
3579 ferrum_types::chat_api_response_from_generated_text(
3580 chat_request,
3581 &parsed.content,
3582 finish_reason,
3583 )
3584 })
3585}
3586
3587fn finish_reason_allows_structured_api_response(finish_reason: FinishReason) -> bool {
3588 matches!(finish_reason, FinishReason::Stop | FinishReason::EOS)
3589}
3590
3591fn log_required_tool_choice_failure(
3592 request: &ChatCompletionsRequest,
3593 content: &str,
3594 reasoning: Option<&str>,
3595) {
3596 warn!(
3597 model = %request.model,
3598 content_len = content.len(),
3599 content_head = %log_excerpt(content, 512),
3600 reasoning_len = reasoning.map(str::len).unwrap_or(0),
3601 reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
3602 "model output did not satisfy required tool_choice"
3603 );
3604}
3605
3606fn log_excerpt(value: &str, max_chars: usize) -> String {
3607 let mut out = value.chars().take(max_chars).collect::<String>();
3608 if value.chars().count() > max_chars {
3609 out.push_str("...");
3610 }
3611 out
3612}
3613
3614fn normalize_structured_response_content(
3615 request: &ChatCompletionsRequest,
3616 content: &str,
3617) -> String {
3618 match EffectiveChatOutputContract::resolve(request) {
3619 EffectiveChatOutputContract::BestEffortJsonSchemaContent => {
3620 extract_json_object_text(content)
3621 .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
3622 }
3623 EffectiveChatOutputContract::RequiredToolCall
3624 | EffectiveChatOutputContract::StrictJsonSchemaContent
3625 | EffectiveChatOutputContract::JsonObjectContent
3626 | EffectiveChatOutputContract::Text => content.to_string(),
3627 }
3628}
3629
3630fn extract_json_object_text(text: &str) -> Option<String> {
3631 let text = strip_markdown_json_fence(text.trim());
3632 if serde_json::from_str::<serde_json::Value>(&text)
3633 .ok()
3634 .filter(|value| value.is_object())
3635 .is_some()
3636 {
3637 return Some(text.to_string());
3638 }
3639
3640 let start = text.find('{')?;
3641 let mut depth = 0usize;
3642 let mut in_string = false;
3643 let mut escaped = false;
3644 for (offset, ch) in text[start..].char_indices() {
3645 if in_string {
3646 if escaped {
3647 escaped = false;
3648 } else if ch == '\\' {
3649 escaped = true;
3650 } else if ch == '"' {
3651 in_string = false;
3652 }
3653 continue;
3654 }
3655 match ch {
3656 '"' => in_string = true,
3657 '{' => depth += 1,
3658 '}' => {
3659 depth = depth.saturating_sub(1);
3660 if depth == 0 {
3661 let end = start + offset + ch.len_utf8();
3662 let candidate = &text[start..end];
3663 if serde_json::from_str::<serde_json::Value>(candidate)
3664 .ok()
3665 .filter(|value| value.is_object())
3666 .is_some()
3667 {
3668 return Some(candidate.to_string());
3669 }
3670 }
3671 }
3672 _ => {}
3673 }
3674 }
3675 None
3676}
3677
3678fn api_chat_request(
3679 request: &ChatCompletionsRequest,
3680 effective_tool_choice: Option<&ToolChoice>,
3681 tool_call_protocol: ferrum_types::ApiToolCallProtocol,
3682) -> ferrum_types::ApiChatRequest {
3683 ferrum_types::ApiChatRequest {
3684 messages: request.messages.iter().map(api_chat_message).collect(),
3685 tools: request
3686 .tools
3687 .as_deref()
3688 .unwrap_or_default()
3689 .iter()
3690 .map(api_tool)
3691 .collect(),
3692 tool_choice: effective_tool_choice.map(api_tool_choice),
3693 tool_call_protocol,
3694 legacy_functions: request
3695 .functions
3696 .as_deref()
3697 .unwrap_or_default()
3698 .iter()
3699 .map(api_function)
3700 .collect(),
3701 legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
3702 response_format: request.response_format.as_ref().map(api_response_format),
3703 stream_options: request.stream_options.as_ref().map(|opts| {
3704 ferrum_types::ApiStreamOptions {
3705 include_usage: opts.include_usage,
3706 }
3707 }),
3708 }
3709}
3710
3711fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
3712 ferrum_types::ApiChatMessage {
3713 role: match message.role {
3714 MessageRole::System => ferrum_types::ApiMessageRole::System,
3715 MessageRole::User => ferrum_types::ApiMessageRole::User,
3716 MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
3717 MessageRole::Function => ferrum_types::ApiMessageRole::Function,
3718 MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
3719 },
3720 content: message.content.clone(),
3721 name: message.name.clone(),
3722 tool_calls: message
3723 .tool_calls
3724 .as_deref()
3725 .unwrap_or_default()
3726 .iter()
3727 .map(api_tool_call)
3728 .collect(),
3729 tool_call_id: message.tool_call_id.clone(),
3730 function_call: message.function_call.as_ref().map(api_function_call),
3731 }
3732}
3733
3734fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
3735 ferrum_types::ApiTool {
3736 tool_type: tool.tool_type.clone(),
3737 function: api_function(&tool.function),
3738 }
3739}
3740
3741fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
3742 ferrum_types::ApiFunction {
3743 name: function.name.clone(),
3744 description: function.description.clone(),
3745 parameters: function.parameters.clone(),
3746 strict: function.strict,
3747 }
3748}
3749
3750fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
3751 match choice {
3752 ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
3753 ToolChoice::Function {
3754 tool_type,
3755 function,
3756 } => ferrum_types::ApiToolChoice::Function {
3757 tool_type: tool_type.clone(),
3758 function: ferrum_types::ApiToolChoiceFunction {
3759 name: function.name.clone(),
3760 },
3761 },
3762 }
3763}
3764
3765fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
3766 match choice {
3767 FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
3768 FunctionCallChoice::Function { name } => {
3769 ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
3770 }
3771 }
3772}
3773
3774fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
3775 ferrum_types::ApiToolCall {
3776 id: tool_call.id.clone(),
3777 tool_type: tool_call.tool_type.clone(),
3778 function: api_function_call(&tool_call.function),
3779 }
3780}
3781
3782fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
3783 ferrum_types::ApiFunctionCall {
3784 name: function_call.name.clone(),
3785 arguments: function_call.arguments.clone(),
3786 }
3787}
3788
3789fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
3790 ChatMessage {
3791 role: openai_message_role_from_api(message.role),
3792 content: message.content.clone(),
3793 reasoning: None,
3794 name: message.name.clone(),
3795 tool_calls: if message.tool_calls.is_empty() {
3796 None
3797 } else {
3798 Some(
3799 message
3800 .tool_calls
3801 .iter()
3802 .map(openai_tool_call_from_api)
3803 .collect(),
3804 )
3805 },
3806 tool_call_id: message.tool_call_id.clone(),
3807 function_call: message
3808 .function_call
3809 .as_ref()
3810 .map(openai_function_call_from_api),
3811 }
3812}
3813
3814fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
3815 match role {
3816 ferrum_types::ApiMessageRole::System => MessageRole::System,
3817 ferrum_types::ApiMessageRole::User => MessageRole::User,
3818 ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
3819 ferrum_types::ApiMessageRole::Function => MessageRole::Function,
3820 ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
3821 }
3822}
3823
3824fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
3825 ChatToolCall {
3826 index: None,
3827 id: tool_call.id.clone(),
3828 tool_type: tool_call.tool_type.clone(),
3829 function: openai_function_call_from_api(&tool_call.function),
3830 }
3831}
3832
3833fn openai_tool_call_delta_from_api(
3834 index: usize,
3835 tool_call: &ferrum_types::ApiToolCall,
3836) -> ChatToolCall {
3837 ChatToolCall {
3838 index: Some(usize_to_u32_saturating(index)),
3839 id: tool_call.id.clone(),
3840 tool_type: tool_call.tool_type.clone(),
3841 function: openai_function_call_from_api(&tool_call.function),
3842 }
3843}
3844
3845fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
3846 let mut delta = openai_chat_message_from_api(message);
3847 if !message.tool_calls.is_empty() {
3848 delta.tool_calls = Some(
3849 message
3850 .tool_calls
3851 .iter()
3852 .enumerate()
3853 .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
3854 .collect(),
3855 );
3856 }
3857 delta
3858}
3859
3860fn openai_function_call_from_api(
3861 function_call: &ferrum_types::ApiFunctionCall,
3862) -> ChatFunctionCall {
3863 ChatFunctionCall {
3864 name: function_call.name.clone(),
3865 arguments: function_call.arguments.clone(),
3866 }
3867}
3868
3869fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
3870 ferrum_types::ApiResponseFormat {
3871 format_type: format.format_type.clone(),
3872 json_schema: format
3873 .json_schema
3874 .as_ref()
3875 .map(|schema| ferrum_types::ApiJsonSchema {
3876 name: schema.name.clone(),
3877 schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
3878 strict: schema.strict,
3879 }),
3880 }
3881}
3882
3883fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
3884 if request.messages.is_empty() {
3885 return Err(ServerError::invalid_request(
3886 "messages array must not be empty",
3887 Some("messages"),
3888 ));
3889 }
3890
3891 if let Some(n) = request.n {
3892 if n != 1 {
3893 return Err(ServerError::unsupported_feature(
3894 "only n=1 is supported for chat completions",
3895 Some("n"),
3896 ));
3897 }
3898 }
3899
3900 if request
3901 .logit_bias
3902 .as_ref()
3903 .is_some_and(|bias| !bias.is_empty())
3904 {
3905 return Err(ServerError::unsupported_feature(
3906 "logit_bias is not supported",
3907 Some("logit_bias"),
3908 ));
3909 }
3910 if request.logprobs.unwrap_or(false) {
3911 return Err(ServerError::unsupported_feature(
3912 "logprobs is not supported",
3913 Some("logprobs"),
3914 ));
3915 }
3916 if request.top_logprobs.unwrap_or(0) > 0 {
3917 return Err(ServerError::unsupported_feature(
3918 "top_logprobs is not supported",
3919 Some("top_logprobs"),
3920 ));
3921 }
3922
3923 if let Some(top_k) = request.top_k {
3924 if top_k < -1 {
3925 return Err(ServerError::invalid_request(
3926 "top_k must be -1, 0, or a positive integer",
3927 Some("top_k"),
3928 ));
3929 }
3930 }
3931 if let Some(min_p) = request.min_p {
3932 if !min_p.is_finite() || !(0.0..=1.0).contains(&min_p) {
3933 return Err(ServerError::invalid_request(
3934 "min_p must be in range [0, 1]",
3935 Some("min_p"),
3936 ));
3937 }
3938 }
3939 if let Some(repetition_penalty) = request.repetition_penalty {
3940 if !repetition_penalty.is_finite() || repetition_penalty <= 0.0 {
3941 return Err(ServerError::invalid_request(
3942 "repetition_penalty must be positive",
3943 Some("repetition_penalty"),
3944 ));
3945 }
3946 }
3947 if let Some(presence_penalty) = request.presence_penalty {
3948 if !presence_penalty.is_finite() || !(-2.0..=2.0).contains(&presence_penalty) {
3949 return Err(ServerError::invalid_request(
3950 "presence_penalty must be in range [-2, 2]",
3951 Some("presence_penalty"),
3952 ));
3953 }
3954 }
3955 if let Some(frequency_penalty) = request.frequency_penalty {
3956 if !frequency_penalty.is_finite() || !(-2.0..=2.0).contains(&frequency_penalty) {
3957 return Err(ServerError::invalid_request(
3958 "frequency_penalty must be in range [-2, 2]",
3959 Some("frequency_penalty"),
3960 ));
3961 }
3962 }
3963
3964 if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
3965 return Err(ServerError::invalid_request(
3966 "stream_options is only valid when stream=true",
3967 Some("stream_options"),
3968 ));
3969 }
3970 ensure_response_format_supported(request)?;
3971
3972 if let Some(tools) = &request.tools {
3973 for tool in tools {
3974 if tool.tool_type != "function" {
3975 return Err(ServerError::unsupported_feature(
3976 "only function tools are supported",
3977 Some("tools"),
3978 ));
3979 }
3980 }
3981 }
3982
3983 if let Some(choice) = &request.tool_choice {
3984 match choice {
3985 ToolChoice::Mode(mode)
3986 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
3987 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
3988 if request.tools.as_deref().unwrap_or_default().is_empty() {
3989 return Err(ServerError::invalid_request(
3990 "tool_choice=required requires at least one function tool",
3991 Some("tool_choice"),
3992 ));
3993 }
3994 }
3995 ToolChoice::Mode(_) => {
3996 return Err(ServerError::unsupported_feature(
3997 "unsupported tool_choice mode",
3998 Some("tool_choice"),
3999 ));
4000 }
4001 ToolChoice::Function {
4002 tool_type,
4003 function,
4004 } => {
4005 if tool_type != "function" {
4006 return Err(ServerError::unsupported_feature(
4007 "only function tool_choice is supported",
4008 Some("tool_choice"),
4009 ));
4010 }
4011 let declared = request
4012 .tools
4013 .as_deref()
4014 .unwrap_or_default()
4015 .iter()
4016 .any(|tool| tool.function.name == function.name);
4017 if !declared {
4018 return Err(ServerError::invalid_request(
4019 "tool_choice selects a function that is not declared in tools",
4020 Some("tool_choice"),
4021 ));
4022 }
4023 }
4024 }
4025 }
4026
4027 if let Some(choice) = &request.function_call {
4028 match choice {
4029 FunctionCallChoice::Mode(mode)
4030 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4031 FunctionCallChoice::Mode(_) => {
4032 return Err(ServerError::unsupported_feature(
4033 "unsupported function_call mode",
4034 Some("function_call"),
4035 ));
4036 }
4037 FunctionCallChoice::Function { name } => {
4038 let declared = request
4039 .functions
4040 .as_deref()
4041 .unwrap_or_default()
4042 .iter()
4043 .any(|function| function.name == *name);
4044 if !declared {
4045 return Err(ServerError::invalid_request(
4046 "function_call selects a function that is not declared in functions",
4047 Some("function_call"),
4048 ));
4049 }
4050 }
4051 }
4052 }
4053
4054 Ok(())
4055}
4056
4057fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
4058 match request.tool_choice.as_ref() {
4059 Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
4060 Some(ToolChoice::Function {
4061 tool_type,
4062 function,
4063 }) => {
4064 tool_type == "function"
4065 && request
4066 .tools
4067 .as_deref()
4068 .unwrap_or_default()
4069 .iter()
4070 .any(|tool| tool.function.name == function.name)
4071 }
4072 _ => false,
4073 }
4074}
4075
4076fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
4077 let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
4078 let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
4079 let total_tokens = usize_to_u32_saturating(usage.total_tokens);
4080 Usage {
4081 prompt_tokens,
4082 completion_tokens,
4083 total_tokens,
4084 }
4085}
4086
4087fn usize_to_u32_saturating(value: usize) -> u32 {
4088 u32::try_from(value).unwrap_or(u32::MAX)
4089}
4090
4091fn ensure_response_format_supported(
4092 request: &ChatCompletionsRequest,
4093) -> std::result::Result<(), ServerError> {
4094 if let Some(rf) = &request.response_format {
4095 match rf.format_type.as_str() {
4096 "text" | "json_object" => {}
4097 "json_schema" => {
4098 let Some(schema_config) = rf.json_schema.as_ref() else {
4099 return Err(ServerError::invalid_request(
4100 "response_format.json_schema.schema is required",
4101 Some("response_format.json_schema"),
4102 ));
4103 };
4104 let Some(schema) = schema_config.schema.as_ref() else {
4105 return Err(ServerError::invalid_request(
4106 "response_format.json_schema.schema is required",
4107 Some("response_format.json_schema"),
4108 ));
4109 };
4110 if schema_config.strict.unwrap_or(false) {
4111 compiled_json_schema_validator(schema).map_err(|reason| {
4112 ServerError::invalid_request(
4113 format!("unsupported strict json_schema: {reason}"),
4114 Some("response_format.json_schema"),
4115 )
4116 })?;
4117 }
4118 }
4119 _ => {
4120 return Err(ServerError::invalid_request(
4121 "unsupported response_format.type",
4122 Some("response_format.type"),
4123 ));
4124 }
4125 }
4126 }
4127 Ok(())
4128}
4129
4130fn strict_json_schema_string(
4131 request: &ChatCompletionsRequest,
4132) -> std::result::Result<Option<String>, ServerError> {
4133 let Some(rf) = &request.response_format else {
4134 return Ok(None);
4135 };
4136 if rf.format_type != "json_schema" {
4137 return Ok(None);
4138 }
4139 let Some(schema) = &rf.json_schema else {
4140 return Err(ServerError::invalid_request(
4141 "response_format.json_schema.schema is required",
4142 Some("response_format.json_schema"),
4143 ));
4144 };
4145 let Some(schema_value) = schema.schema.as_ref() else {
4146 return Err(ServerError::invalid_request(
4147 "response_format.json_schema.schema is required",
4148 Some("response_format.json_schema"),
4149 ));
4150 };
4151 if !schema.strict.unwrap_or(false) {
4152 return Ok(None);
4153 }
4154 serde_json::to_string(schema_value).map(Some).map_err(|e| {
4155 ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
4156 })
4157}
4158
4159fn validate_hard_structured_response(
4160 request: &ChatCompletionsRequest,
4161 content: &str,
4162) -> std::result::Result<(), ServerError> {
4163 match EffectiveChatOutputContract::resolve(request) {
4164 EffectiveChatOutputContract::JsonObjectContent => {
4165 let value = serde_json::from_str::<serde_json::Value>(content).map_err(|error| {
4166 ServerError::InternalError(format!(
4167 "model output did not satisfy response_format.json_object: invalid JSON: {error}"
4168 ))
4169 })?;
4170 if !value.is_object() {
4171 return Err(ServerError::InternalError(
4172 "model output did not satisfy response_format.json_object: root must be an object"
4173 .to_string(),
4174 ));
4175 }
4176 Ok(())
4177 }
4178 EffectiveChatOutputContract::StrictJsonSchemaContent => {
4179 let Some(schema_json) = strict_json_schema_string(request)? else {
4180 return Ok(());
4181 };
4182 let schema: serde_json::Value = serde_json::from_str(&schema_json).map_err(|e| {
4183 ServerError::InternalError(format!(
4184 "strict json_schema could not be reconstructed after request validation: {e}"
4185 ))
4186 })?;
4187 validate_json_text_against_schema(&schema, content).map_err(|reason| {
4188 ServerError::InternalError(format!(
4189 "model output did not satisfy response_format.json_schema.strict: {reason}"
4190 ))
4191 })
4192 }
4193 EffectiveChatOutputContract::RequiredToolCall
4194 | EffectiveChatOutputContract::BestEffortJsonSchemaContent
4195 | EffectiveChatOutputContract::Text => Ok(()),
4196 }
4197}
4198
4199fn structured_response_error_param(contract: EffectiveChatOutputContract) -> Option<&'static str> {
4200 match contract {
4201 EffectiveChatOutputContract::JsonObjectContent => Some("response_format"),
4202 EffectiveChatOutputContract::StrictJsonSchemaContent => Some("response_format.json_schema"),
4203 _ => None,
4204 }
4205}
4206
4207fn validate_structured_tool_response(
4208 request: &ChatCompletionsRequest,
4209 response: &ferrum_types::ApiChatResponse,
4210) -> std::result::Result<(), ServerError> {
4211 let required = tool_choice_required(request);
4212 if response.message.tool_calls.is_empty() {
4213 if required {
4214 return Err(ServerError::invalid_request(
4215 "model output did not satisfy required tool_choice",
4216 Some("tool_choice"),
4217 ));
4218 }
4219 return Ok(());
4220 }
4221
4222 if required {
4223 if !response.message.content.trim().is_empty() {
4224 return Err(ServerError::InternalError(
4225 "required tool response contained assistant content".to_string(),
4226 ));
4227 }
4228 if response.finish_reason.as_deref() != Some("tool_calls") {
4229 return Err(ServerError::InternalError(
4230 "required tool response did not finish with tool_calls".to_string(),
4231 ));
4232 }
4233 }
4234
4235 let tools = request.tools.as_deref().unwrap_or_default();
4236 for call in &response.message.tool_calls {
4237 if call.tool_type != "function" {
4238 return Err(ServerError::InternalError(format!(
4239 "model emitted unsupported tool call type '{}'",
4240 call.tool_type
4241 )));
4242 }
4243 let Some(tool) = tools
4244 .iter()
4245 .find(|tool| tool.tool_type == "function" && tool.function.name == call.function.name)
4246 else {
4247 return Err(ServerError::InternalError(format!(
4248 "model emitted undeclared tool call '{}'",
4249 call.function.name
4250 )));
4251 };
4252 if let Some(ToolChoice::Function {
4253 tool_type,
4254 function,
4255 }) = request.tool_choice.as_ref()
4256 {
4257 if tool_type != "function" || function.name != call.function.name {
4258 return Err(ServerError::InternalError(format!(
4259 "model emitted tool '{}' instead of selected tool '{}'",
4260 call.function.name, function.name
4261 )));
4262 }
4263 }
4264
4265 let arguments: serde_json::Value =
4266 serde_json::from_str(&call.function.arguments).map_err(|e| {
4267 ServerError::InternalError(format!(
4268 "model emitted invalid JSON arguments for tool '{}': {e}",
4269 call.function.name
4270 ))
4271 })?;
4272 if !arguments.is_object() {
4273 return Err(ServerError::InternalError(format!(
4274 "model emitted non-object arguments for tool '{}'",
4275 call.function.name
4276 )));
4277 }
4278 if let Some(schema) = tool.function.parameters.as_ref() {
4279 validate_json_text_against_schema(schema, &call.function.arguments).map_err(
4280 |reason| {
4281 ServerError::InternalError(format!(
4282 "model arguments for tool '{}' did not satisfy its schema: {reason}",
4283 call.function.name
4284 ))
4285 },
4286 )?;
4287 }
4288 }
4289 Ok(())
4290}
4291
4292fn validate_json_text_against_schema(
4293 schema: &serde_json::Value,
4294 content: &str,
4295) -> std::result::Result<(), String> {
4296 let value = serde_json::from_str::<serde_json::Value>(content)
4297 .map_err(|e| format!("invalid JSON: {e}"))?;
4298 compiled_json_schema_validator(schema)?
4299 .validate(&value)
4300 .map_err(|error| error.to_string())
4301}
4302
4303fn compiled_json_schema_validator(
4304 schema: &serde_json::Value,
4305) -> std::result::Result<Arc<jsonschema::Validator>, String> {
4306 let cache_key = serde_json::to_string(schema)
4307 .map_err(|error| format!("could not serialize JSON Schema: {error}"))?;
4308 let cache = JSON_SCHEMA_VALIDATOR_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
4309 let mut validators = cache
4310 .lock()
4311 .map_err(|_| "JSON Schema validator cache lock was poisoned".to_string())?;
4312 if let Some(validator) = validators.get(&cache_key) {
4313 return Ok(Arc::clone(validator));
4314 }
4315
4316 let validator = Arc::new(
4317 jsonschema::validator_for(schema)
4318 .map_err(|error| format!("could not compile JSON Schema: {error}"))?,
4319 );
4320 if validators.len() >= MAX_CACHED_JSON_SCHEMA_VALIDATORS {
4321 validators.clear();
4322 }
4323 validators.insert(cache_key, Arc::clone(&validator));
4324 Ok(validator)
4325}
4326
4327fn stream_validation_error_message(error: ServerError) -> String {
4328 match error {
4329 ServerError::InternalError(message)
4330 | ServerError::NotImplemented(message)
4331 | ServerError::ServiceUnavailable(message)
4332 | ServerError::InvalidRequest { message, .. }
4333 | ServerError::UnsupportedFeature { message, .. } => message,
4334 }
4335}
4336
4337fn server_error_from_ferrum_error(error: Error) -> ServerError {
4338 match error {
4339 Error::RequestValidation { message } => ServerError::invalid_request(message, None),
4340 Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
4341 other => ServerError::InternalError(other.to_string()),
4342 }
4343}
4344
4345fn stream_error_payload(
4346 message: impl Into<String>,
4347 error_type: &str,
4348 param: Option<&str>,
4349) -> OpenAiError {
4350 OpenAiError {
4351 error: OpenAiErrorDetail {
4352 message: message.into(),
4353 error_type: error_type.to_string(),
4354 param: param.map(str::to_string),
4355 code: None,
4356 },
4357 }
4358}
4359
4360fn openai_error_sse_event(
4361 message: impl Into<String>,
4362 error_type: &str,
4363 param: Option<&str>,
4364) -> Event {
4365 Event::default()
4366 .json_data(&stream_error_payload(message, error_type, param))
4367 .unwrap_or_else(|_| Event::default().data("error"))
4368}
4369
4370fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
4371 let prompt = request
4372 .prompt
4373 .as_text()
4374 .expect("completion prompt validated before conversion");
4375 InferenceRequest {
4376 id: RequestId(Uuid::new_v4()),
4377 model_id: ModelId(request.model.clone()),
4378 prompt: prompt.to_string(),
4379 sampling_params: SamplingParams {
4380 max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
4381 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
4382 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
4383 top_k: None,
4384 repetition_penalty: 1.0,
4385 presence_penalty: 0.0,
4386 frequency_penalty: 0.0,
4387 stop_sequences: request.stop.clone().unwrap_or_default(),
4388 seed: None,
4389 min_p: None,
4390 tfs: None,
4391 typical_p: None,
4392 mirostat: None,
4393 response_format: ferrum_types::ResponseFormat::Text,
4394 structured_output_start: StructuredOutputStart::Immediate,
4395 response_completion_boundary: ResponseCompletionBoundary::Immediate,
4396 },
4397 stream: request.stream.unwrap_or(false),
4398 priority: Priority::Normal,
4399 client_id: None,
4400 session_id: None,
4401 created_at: chrono::Utc::now(),
4402 api_request: Some(ferrum_types::ApiRequest::Completion(
4403 ferrum_types::ApiCompletionRequest {
4404 prompt: prompt.to_string(),
4405 response_format: None,
4406 },
4407 )),
4408 evidence_request: Default::default(),
4409 metadata: if request.max_tokens.is_none() {
4410 HashMap::from([(
4411 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
4412 serde_json::json!(true),
4413 )])
4414 } else {
4415 HashMap::new()
4416 },
4417 }
4418}
4419
4420fn resolve_request_model<'a>(
4421 registry: &'a ServedModelRegistry,
4422 request_model: &str,
4423 required_kind: ServedModelKind,
4424) -> std::result::Result<(ModelId, Option<&'a LoraAdapterModel>), ServerError> {
4425 if registry.is_empty() {
4426 return Ok((ModelId::new(request_model), None));
4427 }
4428 let entry = registry
4429 .resolve(request_model, required_kind)
4430 .ok_or_else(|| {
4431 ServerError::invalid_request(format!("unknown model: {request_model}"), Some("model"))
4432 })?;
4433 Ok((entry.engine_model_id().clone(), entry.adapter()))
4434}
4435
4436fn apply_served_model_resolution(
4437 inference_request: &mut InferenceRequest,
4438 engine_model_id: ModelId,
4439 adapter: Option<&LoraAdapterModel>,
4440) {
4441 inference_request.model_id = engine_model_id;
4442 if let Some(adapter) = adapter {
4443 inference_request.metadata.insert(
4444 "ferrum_lora_adapter".to_string(),
4445 serde_json::json!(adapter.name),
4446 );
4447 inference_request.metadata.insert(
4448 "ferrum_lora_model_id".to_string(),
4449 serde_json::json!(adapter.model_id),
4450 );
4451 inference_request.metadata.insert(
4452 "ferrum_lora_path".to_string(),
4453 serde_json::json!(adapter.path),
4454 );
4455 }
4456}
4457
4458async fn handle_completions_sync(
4459 state: AppState,
4460 openai_request: CompletionsRequest,
4461 inference_request: InferenceRequest,
4462) -> std::result::Result<Response, ServerError> {
4463 let engine = state.llm.clone().ok_or_else(|| {
4464 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
4465 })?;
4466 match engine.infer(inference_request).await {
4467 Ok(output) => {
4468 let InferenceResponse {
4469 text: output_text,
4470 finish_reason,
4471 usage,
4472 api_response,
4473 ..
4474 } = output;
4475 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
4476 let mut text = strip_after_stop(&output_text, &stop_sequences);
4477 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
4478 if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
4479 api_response.as_ref()
4480 {
4481 text = strip_after_stop(&completion_response.text, &stop_sequences);
4482 if let Some(reason) = &completion_response.finish_reason {
4483 openai_finish_reason = reason.clone();
4484 }
4485 }
4486 let response = CompletionsResponse {
4487 id: Uuid::new_v4().to_string(),
4488 object: "text_completion".to_string(),
4489 created: chrono::Utc::now().timestamp() as u64,
4490 model: openai_request.model,
4491 choices: vec![CompletionChoice {
4492 text,
4493 index: 0,
4494 finish_reason: Some(openai_finish_reason),
4495 }],
4496 usage: Some(openai_usage_from_token_usage(&usage)),
4497 };
4498 Ok(Json(response).into_response())
4499 }
4500 Err(e) => {
4501 error!("Completion generation failed: {}", e);
4502 Err(ServerError::InternalError(e.to_string()))
4503 }
4504 }
4505}
4506
4507async fn handle_completions_stream(
4508 state: AppState,
4509 openai_request: CompletionsRequest,
4510 inference_request: InferenceRequest,
4511) -> std::result::Result<Response, ServerError> {
4512 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
4513 let engine = state.llm.clone().ok_or_else(|| {
4514 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
4515 })?;
4516 let request_id = Uuid::new_v4().to_string();
4517
4518 tokio::spawn(async move {
4519 match engine.infer_stream(inference_request).await {
4520 Ok(mut stream) => {
4521 while let Some(result) = stream.next().await {
4522 match result {
4523 Ok(chunk) => {
4524 let response_chunk = CompletionsResponse {
4525 id: request_id.clone(),
4526 object: "text_completion".to_string(),
4527 created: chrono::Utc::now().timestamp() as u64,
4528 model: openai_request.model.clone(),
4529 choices: vec![CompletionChoice {
4530 text: chunk.text.clone(),
4531 index: 0,
4532 finish_reason: chunk
4533 .finish_reason
4534 .as_ref()
4535 .map(finish_reason_to_string),
4536 }],
4537 usage: None,
4538 };
4539 let event = Event::default()
4540 .json_data(&response_chunk)
4541 .unwrap_or_else(|_| Event::default().data("error"));
4542 if tx.send(Ok(event)).is_err() {
4543 break;
4544 }
4545 if chunk.finish_reason.is_some() {
4546 if let Some(usage) =
4547 chunk.usage.as_ref().map(openai_usage_from_token_usage)
4548 {
4549 let final_chunk = CompletionsResponse {
4550 id: request_id.clone(),
4551 object: "text_completion".to_string(),
4552 created: chrono::Utc::now().timestamp() as u64,
4553 model: openai_request.model.clone(),
4554 choices: vec![],
4555 usage: Some(usage),
4556 };
4557 let event = Event::default()
4558 .json_data(&final_chunk)
4559 .unwrap_or_else(|_| Event::default().data("error"));
4560 let _ = tx.send(Ok(event));
4561 }
4562 let _ = tx.send(Ok(Event::default().data("[DONE]")));
4563 break;
4564 }
4565 }
4566 Err(e) => {
4567 error!("Completion stream generation error: {}", e);
4568 let _ = tx.send(Ok(openai_error_sse_event(
4569 e.to_string(),
4570 "internal_server_error",
4571 None,
4572 )));
4573 let _ = tx.send(Ok(Event::default().data("[DONE]")));
4574 break;
4575 }
4576 }
4577 }
4578 }
4579 Err(e) => {
4580 error!("Failed to start completion stream: {}", e);
4581 let _ = tx.send(Ok(openai_error_sse_event(
4582 e.to_string(),
4583 "internal_server_error",
4584 None,
4585 )));
4586 let _ = tx.send(Ok(Event::default().data("[DONE]")));
4587 }
4588 }
4589 });
4590
4591 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
4592 Ok(Sse::new(stream).into_response())
4593}
4594
4595async fn completions_handler(
4597 State(state): State<AppState>,
4598 request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
4599) -> std::result::Result<Response, ServerError> {
4600 let Json(request) = request.map_err(|e| {
4601 ServerError::invalid_request(format!("invalid completions request: {e}"), None)
4602 })?;
4603 validate_completion_request(&request)?;
4604 let (engine_model_id, lora_adapter) = resolve_request_model(
4605 &state.served_model_registry,
4606 &request.model,
4607 ServedModelKind::Llm,
4608 )?;
4609 let mut inference_request = convert_completion_request(&request);
4610 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
4611 if request.stream.unwrap_or(false) {
4612 handle_completions_stream(state, request, inference_request).await
4613 } else {
4614 handle_completions_sync(state, request, inference_request).await
4615 }
4616}
4617
4618fn validate_completion_request(
4619 request: &CompletionsRequest,
4620) -> std::result::Result<(), ServerError> {
4621 if request.prompt.as_text().is_none() {
4622 return Err(ServerError::invalid_request(
4623 "only string prompt is supported for completions",
4624 Some("prompt"),
4625 ));
4626 }
4627 if let Some(n) = request.n {
4628 if n != 1 {
4629 return Err(ServerError::unsupported_feature(
4630 "only n=1 is supported for completions",
4631 Some("n"),
4632 ));
4633 }
4634 }
4635 if request.logprobs.is_some() {
4636 return Err(ServerError::unsupported_feature(
4637 "logprobs is not supported for completions",
4638 Some("logprobs"),
4639 ));
4640 }
4641 if request
4642 .logit_bias
4643 .as_ref()
4644 .is_some_and(|bias| !bias.is_empty())
4645 {
4646 return Err(ServerError::unsupported_feature(
4647 "logit_bias is not supported",
4648 Some("logit_bias"),
4649 ));
4650 }
4651 Ok(())
4652}
4653
4654async fn embeddings_handler(
4656 State(state): State<AppState>,
4657 request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
4658) -> std::result::Result<Response, ServerError> {
4659 let Json(request) = request.map_err(|e| {
4660 ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
4661 })?;
4662
4663 let span = span!(Level::INFO, "embeddings", model = %request.model);
4664 let _enter = span.enter();
4665
4666 validate_embeddings_request(&request)?;
4667 resolve_request_model(
4668 &state.served_model_registry,
4669 &request.model,
4670 ServedModelKind::Embedding,
4671 )?;
4672
4673 let items: Vec<EmbeddingItem> = match request.input {
4675 EmbeddingInput::Single(text) => vec![EmbeddingItem {
4676 text: Some(text),
4677 image: None,
4678 }],
4679 EmbeddingInput::Batch(texts) => texts
4680 .into_iter()
4681 .map(|t| EmbeddingItem {
4682 text: Some(t),
4683 image: None,
4684 })
4685 .collect(),
4686 EmbeddingInput::SingleObject(item) => vec![item],
4687 EmbeddingInput::BatchObjects(items) => items,
4688 };
4689
4690 if items.is_empty() {
4691 return Err(ServerError::invalid_request(
4692 "input must not be empty",
4693 Some("input"),
4694 ));
4695 }
4696
4697 let mut data = Vec::with_capacity(items.len());
4698 let mut total_tokens = 0u32;
4699
4700 let engine = state.embed.as_ref().ok_or_else(|| {
4701 ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
4702 })?;
4703 for (idx, item) in items.iter().enumerate() {
4704 let embedding = if let Some(ref image) = item.image {
4705 engine
4706 .embed_image(image)
4707 .await
4708 .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
4709 } else if let Some(ref text) = item.text {
4710 total_tokens += text.len() as u32;
4711 engine
4712 .embed_text(text)
4713 .await
4714 .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
4715 } else {
4716 return Err(ServerError::invalid_request(
4717 "each input item must have either text or image",
4718 Some("input"),
4719 ));
4720 };
4721
4722 data.push(EmbeddingData {
4723 object: "embedding".to_string(),
4724 embedding,
4725 index: idx,
4726 });
4727 }
4728
4729 let response = EmbeddingsResponse {
4730 object: "list".to_string(),
4731 data,
4732 model: request.model,
4733 usage: EmbeddingUsage {
4734 prompt_tokens: total_tokens,
4735 total_tokens,
4736 },
4737 };
4738
4739 Ok(Json(response).into_response())
4740}
4741
4742fn validate_embeddings_request(
4743 request: &EmbeddingsRequest,
4744) -> std::result::Result<(), ServerError> {
4745 if let Some(format) = request.encoding_format.as_deref() {
4746 if !format.eq_ignore_ascii_case("float") {
4747 return Err(ServerError::unsupported_feature(
4748 "only encoding_format=float is supported for embeddings",
4749 Some("encoding_format"),
4750 ));
4751 }
4752 }
4753 Ok(())
4754}
4755
4756async fn transcriptions_handler(
4758 State(state): State<AppState>,
4759 multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
4760) -> std::result::Result<Response, ServerError> {
4761 let mut multipart = multipart.map_err(|e| {
4762 ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
4763 })?;
4764
4765 let span = span!(Level::INFO, "transcription");
4766 let _enter = span.enter();
4767
4768 let mut file_data: Option<Vec<u8>> = None;
4769 let mut language: Option<String> = None;
4770 let mut response_format: Option<String> = None;
4771
4772 while let Some(field) = multipart
4773 .next_field()
4774 .await
4775 .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
4776 {
4777 let name = field.name().unwrap_or("").to_string();
4778 match name.as_str() {
4779 "file" => {
4780 file_data = Some(
4781 field
4782 .bytes()
4783 .await
4784 .map_err(|e| {
4785 ServerError::invalid_request(format!("read file: {e}"), Some("file"))
4786 })?
4787 .to_vec(),
4788 );
4789 }
4790 "language" => {
4791 language = field.text().await.ok().filter(|s| !s.is_empty());
4792 }
4793 "response_format" => {
4794 response_format = field.text().await.ok().filter(|s| !s.is_empty());
4795 }
4796 _ => {} }
4798 }
4799
4800 validate_transcription_response_format(response_format.as_deref())?;
4801
4802 let data = file_data
4803 .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
4804
4805 let engine = state.transcribe.as_ref().ok_or_else(|| {
4806 ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
4807 })?;
4808 let text = engine
4809 .transcribe_bytes(&data, language.as_deref())
4810 .await
4811 .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
4812
4813 Ok(Json(TranscriptionResponse { text }).into_response())
4814}
4815
4816fn validate_transcription_response_format(
4817 response_format: Option<&str>,
4818) -> std::result::Result<(), ServerError> {
4819 if let Some(format) = response_format {
4820 if !format.eq_ignore_ascii_case("json") {
4821 return Err(ServerError::unsupported_feature(
4822 "only response_format=json is supported for transcriptions",
4823 Some("response_format"),
4824 ));
4825 }
4826 }
4827 Ok(())
4828}
4829
4830async fn speech_handler(
4832 State(state): State<AppState>,
4833 request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
4834) -> std::result::Result<Response, ServerError> {
4835 let Json(request) = request
4836 .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
4837
4838 let response_format = speech_output_format(&request)?;
4839 resolve_request_model(
4840 &state.served_model_registry,
4841 &request.model,
4842 ServedModelKind::Speech,
4843 )?;
4844
4845 let span = span!(Level::INFO, "speech");
4846 let _guard = span.enter();
4847
4848 let language = if request.language.is_empty() || request.language == "auto" {
4849 None
4850 } else {
4851 Some(request.language.as_str())
4852 };
4853
4854 let chunk_frames = 10usize;
4855 let tts = state.tts.as_ref().ok_or_else(|| {
4856 ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
4857 })?;
4858 let sample_rate = tts.tts_sample_rate();
4859
4860 if request.stream {
4861 let (tx, rx) =
4863 mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
4864
4865 let engine = tts.clone();
4866 let text = request.input.clone();
4867 let lang = request.language.clone();
4868
4869 tokio::task::spawn_blocking(move || {
4870 let lang_opt = if lang.is_empty() || lang == "auto" {
4871 None
4872 } else {
4873 Some(lang.as_str())
4874 };
4875 let rt = tokio::runtime::Handle::current();
4876
4877 match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
4878 Ok(chunks) => {
4879 for chunk in &chunks {
4880 let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
4881 let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
4882 }
4883 }
4884 Err(e) => {
4885 error!("TTS error: {e}");
4886 }
4887 }
4888 });
4889
4890 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
4891 let body = axum::body::Body::from_stream(stream);
4892 Ok(Response::builder()
4893 .status(200)
4894 .header("content-type", speech_content_type(response_format))
4895 .header("transfer-encoding", "chunked")
4896 .body(body)
4897 .unwrap())
4898 } else {
4899 let chunks = tts
4901 .synthesize_speech(&request.input, language, chunk_frames)
4902 .await
4903 .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
4904
4905 let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
4906 let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
4907
4908 Ok(Response::builder()
4909 .status(200)
4910 .header("content-type", speech_content_type(response_format))
4911 .header("content-length", audio_bytes.len().to_string())
4912 .body(axum::body::Body::from(audio_bytes))
4913 .unwrap())
4914 }
4915}
4916
4917#[derive(Clone, Copy)]
4918enum SpeechOutputFormat {
4919 Wav,
4920 Pcm,
4921}
4922
4923fn speech_output_format(
4924 request: &SpeechRequest,
4925) -> std::result::Result<SpeechOutputFormat, ServerError> {
4926 if request.response_format.eq_ignore_ascii_case("wav") {
4927 Ok(SpeechOutputFormat::Wav)
4928 } else if request.response_format.eq_ignore_ascii_case("pcm") {
4929 Ok(SpeechOutputFormat::Pcm)
4930 } else {
4931 Err(ServerError::unsupported_feature(
4932 "only response_format=wav or response_format=pcm is supported for speech",
4933 Some("response_format"),
4934 ))
4935 }
4936}
4937
4938fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
4939 match format {
4940 SpeechOutputFormat::Wav => "audio/wav",
4941 SpeechOutputFormat::Pcm => "audio/pcm",
4942 }
4943}
4944
4945fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
4946 match format {
4947 SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
4948 SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
4949 }
4950}
4951
4952fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
4954 let num_samples = samples.len();
4955 let data_size = num_samples * 2; let file_size = 44 + data_size;
4957
4958 let mut buf = Vec::with_capacity(file_size);
4959 buf.extend_from_slice(b"RIFF");
4961 buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
4962 buf.extend_from_slice(b"WAVE");
4963 buf.extend_from_slice(b"fmt ");
4965 buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes());
4969 buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); buf.extend_from_slice(&2u16.to_le_bytes()); buf.extend_from_slice(&16u16.to_le_bytes()); buf.extend_from_slice(b"data");
4974 buf.extend_from_slice(&(data_size as u32).to_le_bytes());
4975 buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
4976 buf
4977}
4978
4979fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
4980 let mut buf = Vec::with_capacity(samples.len() * 2);
4981 for &s in samples {
4982 let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
4983 buf.extend_from_slice(&i16_val.to_le_bytes());
4984 }
4985 buf
4986}
4987
4988async fn models_handler(
4989 State(state): State<AppState>,
4990) -> std::result::Result<Response, ServerError> {
4991 let now = chrono::Utc::now().timestamp() as u64;
4992 let data = state
4993 .served_model_registry
4994 .entries()
4995 .iter()
4996 .map(|entry| crate::openai::ModelInfo {
4997 id: entry.public_name().to_string(),
4998 object: "model".to_string(),
4999 created: now,
5000 owned_by: "ferrum".to_string(),
5001 modalities: entry
5002 .kind()
5003 .modalities()
5004 .iter()
5005 .map(ToString::to_string)
5006 .collect(),
5007 permission: vec![],
5008 root: entry.parent_public_name().map(ToString::to_string),
5009 parent: entry.parent_public_name().map(ToString::to_string),
5010 })
5011 .collect();
5012
5013 let models = ModelListResponse {
5014 object: "list".to_string(),
5015 data,
5016 };
5017
5018 Ok(Json(models).into_response())
5019}
5020
5021async fn health_handler(
5022 State(state): State<AppState>,
5023) -> std::result::Result<Response, ServerError> {
5024 let engine_status = state.status().await;
5025 let scheduler_metrics = state.metrics();
5026 let runtime_config = RuntimeConfigSnapshot::capture_current();
5027 let cache_policy = CachePolicy::current();
5028 let engine_cache = state
5029 .llm
5030 .as_ref()
5031 .and_then(|engine| engine.cache_metrics_snapshot());
5032 let engine_lora = state
5033 .llm
5034 .as_ref()
5035 .and_then(|engine| engine.lora_metrics_snapshot());
5036 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5037 let runtime_admission = match state.llm.as_ref() {
5038 Some(engine) => engine.admission_snapshot(),
5039 None => Ok(None),
5040 };
5041 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5042 Ok(snapshot) => (snapshot.as_ref(), None),
5043 Err(error) => (None, Some(error.to_string())),
5044 };
5045 let admission = admission_health_json(
5046 &engine_status,
5047 &scheduler_metrics,
5048 &auto_config,
5049 runtime_admission_snapshot,
5050 runtime_admission_error.as_deref(),
5051 );
5052
5053 let health = serde_json::json!({
5054 "status": if runtime_admission_error.is_some() { "unhealthy" } else { "healthy" },
5055 "timestamp": chrono::Utc::now().to_rfc3339(),
5056 "version": env!("CARGO_PKG_VERSION"),
5057 "engine": {
5058 "active_requests": engine_status.active_requests,
5059 "queued_requests": engine_status.queued_requests,
5060 },
5061 "scheduler": {
5062 "total_requests": scheduler_metrics.total_requests,
5063 "successful_requests": scheduler_metrics.successful_requests,
5064 "failed_requests": scheduler_metrics.failed_requests,
5065 "throughput_rps": scheduler_metrics.throughput_rps,
5066 "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
5067 "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
5068 "model_execution_time_ms": scheduler_metrics
5069 .performance_breakdown
5070 .model_execution_time_ms,
5071 "iteration_lock_wait_time_ms": scheduler_metrics
5072 .performance_breakdown
5073 .other_overhead_time_ms,
5074 },
5075 "config": runtime_config,
5076 "auto_config": auto_config,
5077 "admission": admission,
5078 "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
5079 "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
5080 "enabled": state.served_model_registry.adapter_count() > 0,
5081 "adapter_count": state.served_model_registry.adapter_count() as u64,
5082 "active_cache_bindings": 0u64,
5083 "projection_applications": 0u64,
5084 "position": "startup-routing",
5085 "source": "server-lora-registry",
5086 })),
5087 });
5088
5089 Ok(Json(health).into_response())
5090}
5091
5092async fn metrics_handler(
5094 State(state): State<AppState>,
5095) -> std::result::Result<Response, ServerError> {
5096 let mut body = match PROM_HANDLE.get() {
5097 Some(handle) => handle.render(),
5098 None => "# Prometheus recorder not initialized\n".to_string(),
5099 };
5100 if !body.ends_with('\n') {
5101 body.push('\n');
5102 }
5103 let engine_cache = state
5104 .llm
5105 .as_ref()
5106 .and_then(|engine| engine.cache_metrics_snapshot());
5107 body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
5108 let engine_status = state.status().await;
5109 let scheduler_metrics = state.metrics();
5110 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5111 let runtime_admission = match state.llm.as_ref() {
5112 Some(engine) => engine.admission_snapshot(),
5113 None => Ok(None),
5114 };
5115 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5116 Ok(snapshot) => (snapshot.as_ref(), None),
5117 Err(error) => (None, Some(error.to_string())),
5118 };
5119 let admission = admission_health_json(
5120 &engine_status,
5121 &scheduler_metrics,
5122 &auto_config,
5123 runtime_admission_snapshot,
5124 runtime_admission_error.as_deref(),
5125 );
5126 body.push_str(&admission_prometheus_metrics(&admission));
5127
5128 Ok((
5129 [(
5130 axum::http::header::CONTENT_TYPE,
5131 "text/plain; version=0.0.4; charset=utf-8",
5132 )],
5133 body,
5134 )
5135 .into_response())
5136}
5137
5138async fn root_handler() -> std::result::Result<Response, ServerError> {
5139 let info = serde_json::json!({
5140 "name": "Ferrum Inference Server",
5141 "version": env!("CARGO_PKG_VERSION"),
5142 "api_version": "v1",
5143 "status": "running"
5144 });
5145
5146 Ok(Json(info).into_response())
5147}
5148
5149#[derive(Debug)]
5151enum ServerError {
5152 InvalidRequest {
5153 message: String,
5154 param: Option<String>,
5155 },
5156 UnsupportedFeature {
5157 message: String,
5158 param: Option<String>,
5159 },
5160 InternalError(String),
5161 NotImplemented(String),
5162 ServiceUnavailable(String),
5163}
5164
5165impl ServerError {
5166 fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
5167 Self::InvalidRequest {
5168 message: message.into(),
5169 param: param.map(str::to_string),
5170 }
5171 }
5172
5173 fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
5174 Self::UnsupportedFeature {
5175 message: message.into(),
5176 param: param.map(str::to_string),
5177 }
5178 }
5179}
5180
5181impl IntoResponse for ServerError {
5182 fn into_response(self) -> Response {
5183 let (status, message, error_type, param) = match self {
5184 ServerError::InvalidRequest { message, param } => (
5185 AxumStatusCode::BAD_REQUEST,
5186 message,
5187 "invalid_request_error",
5188 param,
5189 ),
5190 ServerError::UnsupportedFeature { message, param } => (
5191 AxumStatusCode::BAD_REQUEST,
5192 message,
5193 "invalid_request_error",
5194 param,
5195 ),
5196 ServerError::InternalError(msg) => (
5197 AxumStatusCode::INTERNAL_SERVER_ERROR,
5198 msg,
5199 "internal_server_error",
5200 None,
5201 ),
5202 ServerError::NotImplemented(msg) => (
5203 AxumStatusCode::SERVICE_UNAVAILABLE,
5204 msg,
5205 "service_unavailable_error",
5206 None,
5207 ),
5208 ServerError::ServiceUnavailable(msg) => (
5209 AxumStatusCode::SERVICE_UNAVAILABLE,
5210 msg,
5211 "service_unavailable_error",
5212 None,
5213 ),
5214 };
5215
5216 let error = OpenAiError {
5217 error: OpenAiErrorDetail {
5218 message,
5219 error_type: error_type.to_string(),
5220 param,
5221 code: None,
5222 },
5223 };
5224
5225 (status, Json(error)).into_response()
5226 }
5227}
5228
5229impl std::fmt::Display for MessageRole {
5230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5231 match self {
5232 MessageRole::System => write!(f, "system"),
5233 MessageRole::User => write!(f, "user"),
5234 MessageRole::Assistant => write!(f, "assistant"),
5235 MessageRole::Function => write!(f, "function"),
5236 MessageRole::Tool => write!(f, "tool"),
5237 }
5238 }
5239}
5240
5241fn strip_after_stop(text: &str, stops: &[String]) -> String {
5245 let mut first: Option<usize> = None;
5246 for stop in stops {
5247 if stop.is_empty() {
5248 continue;
5249 }
5250 if let Some(idx) = text.find(stop.as_str()) {
5251 first = Some(first.map_or(idx, |current| current.min(idx)));
5252 }
5253 }
5254 match first {
5255 Some(idx) => text[..idx].to_string(),
5256 None => text.to_string(),
5257 }
5258}
5259
5260fn strip_markdown_json_fence(text: &str) -> String {
5263 let trimmed = text.trim();
5264 for prefix in ["```json\n", "```json", "```\n", "```"] {
5266 if let Some(rest) = trimmed.strip_prefix(prefix) {
5267 if let Some(inner) = rest.strip_suffix("```") {
5268 return inner.trim().to_string();
5269 }
5270 }
5271 }
5272 text.to_string()
5273}
5274
5275fn finish_reason_to_string(reason: &FinishReason) -> String {
5277 match reason {
5278 FinishReason::Length => "length".to_string(),
5279 FinishReason::Stop => "stop".to_string(),
5280 FinishReason::EOS => "stop".to_string(),
5281 FinishReason::Cancelled => "cancelled".to_string(),
5282 FinishReason::Error => "error".to_string(),
5283 FinishReason::ContentFilter => "content_filter".to_string(),
5284 }
5285}
5286
5287#[cfg(test)]
5288mod tests {
5289 use super::*;
5290 use async_trait::async_trait;
5291 use axum::{
5292 body::{to_bytes, Body},
5293 http::{header, Request},
5294 response::Response,
5295 };
5296 use ferrum_interfaces::engine::{
5297 EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
5298 };
5299 use ferrum_types::{
5300 EngineConfig, EngineMetrics, EngineStatus, EngineTokenTimingEvidence, FinishReason,
5301 HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
5302 ModelId, StreamChunk, TokenId, TokenUsage,
5303 };
5304 use futures::{stream, Stream};
5305 use serde_json::{json, Value};
5306 use std::{
5307 collections::HashMap,
5308 pin::Pin,
5309 sync::{atomic::AtomicUsize, Arc, Mutex},
5310 };
5311 use tower::ServiceExt;
5312
5313 #[test]
5314 fn strip_after_stop_removes_first_boundary() {
5315 assert_eq!(
5316 strip_after_stop(
5317 "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
5318 &["END0214Z".to_string()]
5319 ),
5320 "KS0214Z\nS0225\n"
5321 );
5322 }
5323
5324 #[tokio::test]
5325 async fn stop_drains_running_server_and_shuts_down_loaded_engine_once() {
5326 let engine = Arc::new(StubLlm::new("ok"));
5327 let server = Arc::new(AxumServer::from_llm(engine.clone()));
5328 let config = ServerConfig {
5329 host: "127.0.0.1".to_string(),
5330 port: 0,
5331 ..ServerConfig::default()
5332 };
5333 let server_task = {
5334 let server = Arc::clone(&server);
5335 tokio::spawn(async move { server.start(&config).await })
5336 };
5337 tokio::time::timeout(std::time::Duration::from_secs(1), async {
5338 while !server.is_running() {
5339 tokio::task::yield_now().await;
5340 }
5341 })
5342 .await
5343 .unwrap();
5344
5345 server
5346 .stop(std::time::Duration::from_secs(1))
5347 .await
5348 .unwrap();
5349 server
5350 .stop(std::time::Duration::from_secs(1))
5351 .await
5352 .unwrap();
5353 server_task.await.unwrap().unwrap();
5354
5355 assert_eq!(engine.shutdown_count.load(Ordering::Acquire), 1);
5356 assert!(!server.is_running());
5357 }
5358
5359 struct StubLlm {
5360 config: EngineConfig,
5361 text: String,
5362 stream_chunks: Option<Vec<String>>,
5363 stream_final_chunk_separate: bool,
5364 stream_usage: Option<TokenUsage>,
5365 api_response: Option<ferrum_types::ApiResponse>,
5366 finish_reason: FinishReason,
5367 lora_metrics: Option<Value>,
5368 pending_stream_drop_notify: Option<Arc<Notify>>,
5369 shutdown_count: AtomicUsize,
5370 }
5371
5372 impl StubLlm {
5373 fn new(text: &str) -> Self {
5374 let mut config = EngineConfig::default();
5375 config.model.model_id = ModelId::new("stub-model");
5376 Self {
5377 config,
5378 text: text.to_string(),
5379 stream_chunks: None,
5380 stream_final_chunk_separate: false,
5381 stream_usage: Some(TokenUsage::new(5, 1)),
5382 api_response: None,
5383 finish_reason: FinishReason::Stop,
5384 lora_metrics: None,
5385 pending_stream_drop_notify: None,
5386 shutdown_count: AtomicUsize::new(0),
5387 }
5388 }
5389
5390 fn without_stream_usage(text: &str) -> Self {
5391 Self {
5392 stream_usage: None,
5393 ..Self::new(text)
5394 }
5395 }
5396
5397 fn with_stream_chunks(chunks: &[&str]) -> Self {
5398 Self {
5399 text: chunks.concat(),
5400 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
5401 stream_usage: Some(TokenUsage::new(5, chunks.len())),
5402 ..Self::new("")
5403 }
5404 }
5405
5406 fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
5407 Self {
5408 text: chunks.concat(),
5409 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
5410 stream_final_chunk_separate: true,
5411 stream_usage: Some(TokenUsage::new(5, chunks.len())),
5412 ..Self::new("")
5413 }
5414 }
5415
5416 fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
5417 Self {
5418 api_response: Some(api_response),
5419 ..Self::new(text)
5420 }
5421 }
5422
5423 fn with_api_response_and_finish_reason(
5424 text: &str,
5425 api_response: ferrum_types::ApiResponse,
5426 finish_reason: FinishReason,
5427 ) -> Self {
5428 Self {
5429 api_response: Some(api_response),
5430 finish_reason,
5431 ..Self::new(text)
5432 }
5433 }
5434
5435 fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
5436 Self {
5437 lora_metrics: Some(lora_metrics),
5438 ..Self::new(text)
5439 }
5440 }
5441
5442 fn with_pending_stream(drop_notify: Arc<Notify>) -> Self {
5443 Self {
5444 pending_stream_drop_notify: Some(drop_notify),
5445 ..Self::new("")
5446 }
5447 }
5448 }
5449
5450 struct PendingDropStream {
5451 drop_notify: Arc<Notify>,
5452 }
5453
5454 impl Stream for PendingDropStream {
5455 type Item = ferrum_types::Result<StreamChunk>;
5456
5457 fn poll_next(
5458 self: Pin<&mut Self>,
5459 _cx: &mut std::task::Context<'_>,
5460 ) -> std::task::Poll<Option<Self::Item>> {
5461 std::task::Poll::Pending
5462 }
5463 }
5464
5465 impl Drop for PendingDropStream {
5466 fn drop(&mut self) {
5467 self.drop_notify.notify_one();
5468 }
5469 }
5470
5471 struct StubEmbed {
5472 config: EngineConfig,
5473 }
5474
5475 impl StubEmbed {
5476 fn new() -> Self {
5477 let mut config = EngineConfig::default();
5478 config.model.model_id = ModelId::new("stub-embed");
5479 Self { config }
5480 }
5481 }
5482
5483 struct StubTranscribe {
5484 config: EngineConfig,
5485 }
5486
5487 impl StubTranscribe {
5488 fn new() -> Self {
5489 let mut config = EngineConfig::default();
5490 config.model.model_id = ModelId::new("stub-transcribe");
5491 Self { config }
5492 }
5493 }
5494
5495 struct StubTts {
5496 config: EngineConfig,
5497 }
5498
5499 impl StubTts {
5500 fn new() -> Self {
5501 let mut config = EngineConfig::default();
5502 config.model.model_id = ModelId::new("stub-tts");
5503 Self { config }
5504 }
5505 }
5506
5507 struct FailingLlm {
5508 config: EngineConfig,
5509 fail_after_stream_start: bool,
5510 infer_failure: ferrum_types::FerrumError,
5511 stream_start_failure: ferrum_types::FerrumError,
5512 stream_chunk_failure: ferrum_types::FerrumError,
5513 }
5514
5515 impl FailingLlm {
5516 fn new() -> Self {
5517 let mut config = EngineConfig::default();
5518 config.model.model_id = ModelId::new("failing-model");
5519 Self {
5520 config,
5521 fail_after_stream_start: false,
5522 infer_failure: ferrum_types::FerrumError::internal("stub generation failed"),
5523 stream_start_failure: ferrum_types::FerrumError::internal("stub stream failed"),
5524 stream_chunk_failure: ferrum_types::FerrumError::internal(
5525 "stub stream chunk failed",
5526 ),
5527 }
5528 }
5529
5530 fn after_stream_start() -> Self {
5531 Self {
5532 fail_after_stream_start: true,
5533 ..Self::new()
5534 }
5535 }
5536
5537 fn resource_exhausted() -> Self {
5538 let failure = ferrum_types::FerrumError::resource_exhausted(
5539 "admission capacity exhausted while reserving request resources",
5540 );
5541 Self {
5542 infer_failure: failure.clone(),
5543 stream_start_failure: failure.clone(),
5544 stream_chunk_failure: failure,
5545 ..Self::new()
5546 }
5547 }
5548 }
5549
5550 struct CapturingLlm {
5551 config: EngineConfig,
5552 last_request: Mutex<Option<InferenceRequest>>,
5553 }
5554
5555 impl CapturingLlm {
5556 fn new() -> Self {
5557 let mut config = EngineConfig::default();
5558 config.model.model_id = ModelId::new("qwen3");
5559 Self {
5560 config,
5561 last_request: Mutex::new(None),
5562 }
5563 }
5564
5565 fn last_request(&self) -> InferenceRequest {
5566 self.last_request
5567 .lock()
5568 .expect("capture lock")
5569 .clone()
5570 .expect("request captured")
5571 }
5572
5573 fn has_captured_request(&self) -> bool {
5574 self.last_request.lock().expect("capture lock").is_some()
5575 }
5576 }
5577
5578 #[async_trait]
5579 impl InferenceEngine for StubLlm {
5580 async fn status(&self) -> EngineStatus {
5581 EngineStatus {
5582 is_ready: true,
5583 loaded_models: vec![self.config.model.model_id.clone()],
5584 active_requests: 0,
5585 queued_requests: 0,
5586 memory_usage: MemoryUsage {
5587 total_bytes: 0,
5588 used_bytes: 0,
5589 free_bytes: 0,
5590 gpu_memory_bytes: None,
5591 cpu_memory_bytes: None,
5592 cache_memory_bytes: 0,
5593 utilization_percent: 0.0,
5594 },
5595 uptime_seconds: 0,
5596 last_heartbeat: chrono::Utc::now(),
5597 version: "test".to_string(),
5598 }
5599 }
5600
5601 async fn shutdown(&self) -> ferrum_types::Result<()> {
5602 self.shutdown_count.fetch_add(1, Ordering::AcqRel);
5603 Ok(())
5604 }
5605
5606 fn config(&self) -> &EngineConfig {
5607 &self.config
5608 }
5609
5610 fn metrics(&self) -> EngineMetrics {
5611 EngineMetrics::default()
5612 }
5613
5614 async fn health_check(&self) -> EngineHealthStatus {
5615 EngineHealthStatus::healthy()
5616 }
5617
5618 fn lora_metrics_snapshot(&self) -> Option<Value> {
5619 self.lora_metrics.clone()
5620 }
5621 }
5622
5623 #[async_trait]
5624 impl InferenceEngine for StubEmbed {
5625 async fn status(&self) -> EngineStatus {
5626 EngineStatus {
5627 is_ready: true,
5628 loaded_models: vec![self.config.model.model_id.clone()],
5629 active_requests: 0,
5630 queued_requests: 0,
5631 memory_usage: MemoryUsage {
5632 total_bytes: 0,
5633 used_bytes: 0,
5634 free_bytes: 0,
5635 gpu_memory_bytes: None,
5636 cpu_memory_bytes: None,
5637 cache_memory_bytes: 0,
5638 utilization_percent: 0.0,
5639 },
5640 uptime_seconds: 0,
5641 last_heartbeat: chrono::Utc::now(),
5642 version: "test".to_string(),
5643 }
5644 }
5645
5646 async fn shutdown(&self) -> ferrum_types::Result<()> {
5647 Ok(())
5648 }
5649
5650 fn config(&self) -> &EngineConfig {
5651 &self.config
5652 }
5653
5654 fn metrics(&self) -> EngineMetrics {
5655 EngineMetrics::default()
5656 }
5657
5658 async fn health_check(&self) -> EngineHealthStatus {
5659 EngineHealthStatus::healthy()
5660 }
5661 }
5662
5663 #[async_trait]
5664 impl EmbedEngine for StubEmbed {
5665 async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
5666 Ok(vec![text.len() as f32, 1.0, 0.0])
5667 }
5668
5669 async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
5670 Ok(vec![image.len() as f32, 0.0, 1.0])
5671 }
5672
5673 fn embedding_dim(&self) -> usize {
5674 3
5675 }
5676 }
5677
5678 #[async_trait]
5679 impl InferenceEngine for StubTranscribe {
5680 async fn status(&self) -> EngineStatus {
5681 EngineStatus {
5682 is_ready: true,
5683 loaded_models: vec![self.config.model.model_id.clone()],
5684 active_requests: 0,
5685 queued_requests: 0,
5686 memory_usage: MemoryUsage {
5687 total_bytes: 0,
5688 used_bytes: 0,
5689 free_bytes: 0,
5690 gpu_memory_bytes: None,
5691 cpu_memory_bytes: None,
5692 cache_memory_bytes: 0,
5693 utilization_percent: 0.0,
5694 },
5695 uptime_seconds: 0,
5696 last_heartbeat: chrono::Utc::now(),
5697 version: "test".to_string(),
5698 }
5699 }
5700
5701 async fn shutdown(&self) -> ferrum_types::Result<()> {
5702 Ok(())
5703 }
5704
5705 fn config(&self) -> &EngineConfig {
5706 &self.config
5707 }
5708
5709 fn metrics(&self) -> EngineMetrics {
5710 EngineMetrics::default()
5711 }
5712
5713 async fn health_check(&self) -> EngineHealthStatus {
5714 EngineHealthStatus::healthy()
5715 }
5716 }
5717
5718 #[async_trait]
5719 impl TranscribeEngine for StubTranscribe {
5720 async fn transcribe_file(
5721 &self,
5722 path: &str,
5723 language: Option<&str>,
5724 ) -> ferrum_types::Result<String> {
5725 Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
5726 }
5727
5728 async fn transcribe_bytes(
5729 &self,
5730 data: &[u8],
5731 language: Option<&str>,
5732 ) -> ferrum_types::Result<String> {
5733 Ok(format!(
5734 "bytes:{}:{}",
5735 data.len(),
5736 language.unwrap_or("auto")
5737 ))
5738 }
5739 }
5740
5741 #[async_trait]
5742 impl InferenceEngine for StubTts {
5743 async fn status(&self) -> EngineStatus {
5744 EngineStatus {
5745 is_ready: true,
5746 loaded_models: vec![self.config.model.model_id.clone()],
5747 active_requests: 0,
5748 queued_requests: 0,
5749 memory_usage: MemoryUsage {
5750 total_bytes: 0,
5751 used_bytes: 0,
5752 free_bytes: 0,
5753 gpu_memory_bytes: None,
5754 cpu_memory_bytes: None,
5755 cache_memory_bytes: 0,
5756 utilization_percent: 0.0,
5757 },
5758 uptime_seconds: 0,
5759 last_heartbeat: chrono::Utc::now(),
5760 version: "test".to_string(),
5761 }
5762 }
5763
5764 async fn shutdown(&self) -> ferrum_types::Result<()> {
5765 Ok(())
5766 }
5767
5768 fn config(&self) -> &EngineConfig {
5769 &self.config
5770 }
5771
5772 fn metrics(&self) -> EngineMetrics {
5773 EngineMetrics::default()
5774 }
5775
5776 async fn health_check(&self) -> EngineHealthStatus {
5777 EngineHealthStatus::healthy()
5778 }
5779 }
5780
5781 #[async_trait]
5782 impl TtsEngine for StubTts {
5783 async fn synthesize_speech(
5784 &self,
5785 _text: &str,
5786 _language: Option<&str>,
5787 _chunk_frames: usize,
5788 ) -> ferrum_types::Result<Vec<Vec<f32>>> {
5789 Ok(vec![vec![0.0, 0.5, -0.5]])
5790 }
5791
5792 fn tts_sample_rate(&self) -> u32 {
5793 16_000
5794 }
5795 }
5796
5797 #[async_trait]
5798 impl InferenceEngine for FailingLlm {
5799 async fn status(&self) -> EngineStatus {
5800 EngineStatus {
5801 is_ready: true,
5802 loaded_models: vec![self.config.model.model_id.clone()],
5803 active_requests: 0,
5804 queued_requests: 0,
5805 memory_usage: MemoryUsage {
5806 total_bytes: 0,
5807 used_bytes: 0,
5808 free_bytes: 0,
5809 gpu_memory_bytes: None,
5810 cpu_memory_bytes: None,
5811 cache_memory_bytes: 0,
5812 utilization_percent: 0.0,
5813 },
5814 uptime_seconds: 0,
5815 last_heartbeat: chrono::Utc::now(),
5816 version: "test".to_string(),
5817 }
5818 }
5819
5820 async fn shutdown(&self) -> ferrum_types::Result<()> {
5821 Ok(())
5822 }
5823
5824 fn config(&self) -> &EngineConfig {
5825 &self.config
5826 }
5827
5828 fn metrics(&self) -> EngineMetrics {
5829 EngineMetrics::default()
5830 }
5831
5832 async fn health_check(&self) -> EngineHealthStatus {
5833 EngineHealthStatus::healthy()
5834 }
5835 }
5836
5837 #[async_trait]
5838 impl InferenceEngine for CapturingLlm {
5839 async fn status(&self) -> EngineStatus {
5840 EngineStatus {
5841 is_ready: true,
5842 loaded_models: vec![self.config.model.model_id.clone()],
5843 active_requests: 0,
5844 queued_requests: 0,
5845 memory_usage: MemoryUsage {
5846 total_bytes: 0,
5847 used_bytes: 0,
5848 free_bytes: 0,
5849 gpu_memory_bytes: None,
5850 cpu_memory_bytes: None,
5851 cache_memory_bytes: 0,
5852 utilization_percent: 0.0,
5853 },
5854 uptime_seconds: 0,
5855 last_heartbeat: chrono::Utc::now(),
5856 version: "test".to_string(),
5857 }
5858 }
5859
5860 async fn shutdown(&self) -> ferrum_types::Result<()> {
5861 Ok(())
5862 }
5863
5864 fn config(&self) -> &EngineConfig {
5865 &self.config
5866 }
5867
5868 fn metrics(&self) -> EngineMetrics {
5869 EngineMetrics::default()
5870 }
5871
5872 async fn health_check(&self) -> EngineHealthStatus {
5873 EngineHealthStatus::healthy()
5874 }
5875 }
5876
5877 fn stub_execution_evidence(
5878 request: &InferenceRequest,
5879 output_token_count: usize,
5880 ) -> Option<InferenceExecutionEvidence> {
5881 let requested = &request.evidence_request;
5882 if !requested.capture_prompt_token_ids && !requested.capture_engine_token_timing {
5883 return None;
5884 }
5885 Some(InferenceExecutionEvidence {
5886 prompt_token_ids: requested
5887 .capture_prompt_token_ids
5888 .then(|| vec![TokenId::new(101), TokenId::new(202), TokenId::new(303)])
5889 .unwrap_or_default(),
5890 output_token_ids: (0..output_token_count)
5891 .map(|index| TokenId::new(11 + index as u32))
5892 .collect(),
5893 engine_token_timing: requested.capture_engine_token_timing.then(|| {
5894 EngineTokenTimingEvidence {
5895 clock_source: "rust_std_instant".to_string(),
5896 wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
5897 wall_anchor_max_error_nanos: 500,
5898 decode_ready_nanos_since_request_start: Some(1_000_000),
5899 token_commit_nanos_since_request_start: (1..=output_token_count)
5900 .map(|ordinal| ordinal as u64 * 1_000_000)
5901 .collect(),
5902 decode_stage_intervals: Vec::new(),
5903 }
5904 }),
5905 })
5906 }
5907
5908 #[async_trait]
5909 impl LlmInferenceEngine for StubLlm {
5910 async fn infer(
5911 &self,
5912 request: InferenceRequest,
5913 ) -> ferrum_types::Result<InferenceResponse> {
5914 let execution_evidence = stub_execution_evidence(&request, 2);
5915 Ok(InferenceResponse {
5916 request_id: request.id,
5917 text: self.text.clone(),
5918 tokens: vec![TokenId::new(11), TokenId::new(12)],
5919 finish_reason: self.finish_reason,
5920 usage: TokenUsage::new(7, 2),
5921 latency_ms: 1,
5922 created_at: chrono::Utc::now(),
5923 metadata: HashMap::new(),
5924 api_response: self.api_response.clone(),
5925 execution_evidence,
5926 })
5927 }
5928
5929 async fn infer_stream(
5930 &self,
5931 request: InferenceRequest,
5932 ) -> ferrum_types::Result<
5933 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
5934 > {
5935 if let Some(drop_notify) = self.pending_stream_drop_notify.as_ref() {
5936 return Ok(Box::pin(PendingDropStream {
5937 drop_notify: Arc::clone(drop_notify),
5938 }));
5939 }
5940 if let Some(chunks) = &self.stream_chunks {
5941 let completion_token_count = self
5942 .stream_usage
5943 .as_ref()
5944 .map(|usage| usage.completion_tokens)
5945 .unwrap_or(chunks.len());
5946 let execution_evidence = stub_execution_evidence(&request, completion_token_count);
5947 let request_id = request.id;
5948 let mut stream_chunks = Vec::with_capacity(
5949 chunks.len() + usize::from(self.stream_final_chunk_separate),
5950 );
5951 let last = chunks.len().saturating_sub(1);
5952 for (index, text) in chunks.iter().enumerate() {
5953 let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
5954 stream_chunks.push(Ok(StreamChunk {
5955 request_id: request_id.clone(),
5956 text: text.clone(),
5957 token: Some(TokenId::new(11 + index as u32)),
5958 finish_reason: is_final_text_chunk.then_some(self.finish_reason),
5959 usage: is_final_text_chunk
5960 .then(|| self.stream_usage.clone())
5961 .flatten(),
5962 created_at: chrono::Utc::now(),
5963 metadata: HashMap::new(),
5964 api_response: is_final_text_chunk
5965 .then(|| self.api_response.clone())
5966 .flatten(),
5967 execution_evidence: is_final_text_chunk
5968 .then(|| execution_evidence.clone())
5969 .flatten(),
5970 }));
5971 }
5972 if self.stream_final_chunk_separate {
5973 stream_chunks.push(Ok(StreamChunk {
5974 request_id,
5975 text: String::new(),
5976 token: None,
5977 finish_reason: Some(self.finish_reason),
5978 usage: self.stream_usage.clone(),
5979 created_at: chrono::Utc::now(),
5980 metadata: HashMap::new(),
5981 api_response: self.api_response.clone(),
5982 execution_evidence,
5983 }));
5984 }
5985 return Ok(Box::pin(stream::iter(stream_chunks)));
5986 }
5987
5988 let execution_evidence = stub_execution_evidence(&request, 1);
5989 let chunk = StreamChunk {
5990 request_id: request.id,
5991 text: self.text.clone(),
5992 token: Some(TokenId::new(11)),
5993 finish_reason: Some(self.finish_reason),
5994 usage: self.stream_usage.clone(),
5995 created_at: chrono::Utc::now(),
5996 metadata: HashMap::new(),
5997 api_response: self.api_response.clone(),
5998 execution_evidence,
5999 };
6000 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6001 }
6002 }
6003
6004 #[async_trait]
6005 impl LlmInferenceEngine for FailingLlm {
6006 async fn infer(
6007 &self,
6008 _request: InferenceRequest,
6009 ) -> ferrum_types::Result<InferenceResponse> {
6010 Err(self.infer_failure.clone())
6011 }
6012
6013 async fn infer_stream(
6014 &self,
6015 request: InferenceRequest,
6016 ) -> ferrum_types::Result<
6017 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6018 > {
6019 if self.fail_after_stream_start {
6020 let _request_id = request.id;
6021 return Ok(Box::pin(stream::iter(vec![Err(self
6022 .stream_chunk_failure
6023 .clone())])));
6024 }
6025 Err(self.stream_start_failure.clone())
6026 }
6027 }
6028
6029 #[async_trait]
6030 impl LlmInferenceEngine for CapturingLlm {
6031 async fn infer(
6032 &self,
6033 request: InferenceRequest,
6034 ) -> ferrum_types::Result<InferenceResponse> {
6035 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6036 Ok(InferenceResponse {
6037 request_id: request.id,
6038 text: "captured".to_string(),
6039 tokens: vec![TokenId::new(21)],
6040 finish_reason: FinishReason::Stop,
6041 usage: TokenUsage::new(9, 1),
6042 latency_ms: 1,
6043 created_at: chrono::Utc::now(),
6044 metadata: HashMap::new(),
6045 api_response: None,
6046 execution_evidence: None,
6047 })
6048 }
6049
6050 async fn infer_stream(
6051 &self,
6052 request: InferenceRequest,
6053 ) -> ferrum_types::Result<
6054 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6055 > {
6056 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6057 let chunk = StreamChunk {
6058 request_id: request.id,
6059 text: "captured".to_string(),
6060 token: Some(TokenId::new(21)),
6061 finish_reason: Some(FinishReason::Stop),
6062 usage: Some(TokenUsage::new(9, 1)),
6063 created_at: chrono::Utc::now(),
6064 metadata: HashMap::new(),
6065 api_response: None,
6066 execution_evidence: None,
6067 };
6068 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6069 }
6070 }
6071
6072 fn state_with_stub(text: &str) -> AppState {
6073 AppState::default().with_llm(Arc::new(StubLlm::new(text)))
6074 }
6075
6076 fn router_with_stub(text: &str) -> Router {
6077 AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
6078 }
6079
6080 fn router_with_stub_and_template(text: &str, template: ModelChatTemplate) -> Router {
6081 AxumServer::from_llm(Arc::new(StubLlm::new(text)))
6082 .with_prompt_template(Some(template))
6083 .build_router()
6084 }
6085
6086 fn router_with_stub_and_request_dump_dir(text: &str, request_dump_dir: PathBuf) -> Router {
6087 AxumServer::from_state(
6088 AppState::default()
6089 .with_llm(Arc::new(StubLlm::new(text)))
6090 .with_request_dump_dir(Some(request_dump_dir)),
6091 )
6092 .build_router()
6093 }
6094
6095 fn router_with_stub_request_dump_and_profile(
6096 text: &str,
6097 request_dump_dir: PathBuf,
6098 profile_jsonl: PathBuf,
6099 ) -> Router {
6100 AxumServer::from_state(
6101 AppState::default()
6102 .with_llm(Arc::new(StubLlm::new(text)))
6103 .with_request_dump_dir(Some(request_dump_dir))
6104 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6105 .with_profile_jsonl(Some(profile_jsonl)),
6106 )
6107 .build_router()
6108 }
6109
6110 fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
6111 AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
6112 }
6113
6114 fn router_with_stub_stream_chunks_and_request_dump_dir(
6115 chunks: &[&str],
6116 request_dump_dir: PathBuf,
6117 ) -> Router {
6118 AxumServer::from_state(
6119 AppState::default()
6120 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6121 .with_request_dump_dir(Some(request_dump_dir)),
6122 )
6123 .build_router()
6124 }
6125
6126 fn router_with_stub_stream_request_dump_and_profile(
6127 chunks: &[&str],
6128 request_dump_dir: PathBuf,
6129 profile_jsonl: PathBuf,
6130 ) -> Router {
6131 AxumServer::from_state(
6132 AppState::default()
6133 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6134 .with_request_dump_dir(Some(request_dump_dir))
6135 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6136 .with_profile_jsonl(Some(profile_jsonl)),
6137 )
6138 .build_router()
6139 }
6140
6141 fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
6142 AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
6143 .build_router()
6144 }
6145
6146 fn router_with_stub_api_response(
6147 text: &str,
6148 api_response: ferrum_types::ApiResponse,
6149 ) -> Router {
6150 AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
6151 .build_router()
6152 }
6153
6154 fn router_with_stub_api_response_and_finish_reason(
6155 text: &str,
6156 api_response: ferrum_types::ApiResponse,
6157 finish_reason: FinishReason,
6158 ) -> Router {
6159 AxumServer::from_llm(Arc::new(StubLlm::with_api_response_and_finish_reason(
6160 text,
6161 api_response,
6162 finish_reason,
6163 )))
6164 .build_router()
6165 }
6166
6167 fn weather_tool_api_response() -> ferrum_types::ApiResponse {
6168 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6169 message: ferrum_types::ApiChatMessage {
6170 role: ferrum_types::ApiMessageRole::Assistant,
6171 content: String::new(),
6172 name: None,
6173 tool_calls: vec![ferrum_types::ApiToolCall {
6174 id: "call_1".to_string(),
6175 tool_type: "function".to_string(),
6176 function: ferrum_types::ApiFunctionCall {
6177 name: "weather".to_string(),
6178 arguments: "{\"city\":\"Paris\"}".to_string(),
6179 },
6180 }],
6181 tool_call_id: None,
6182 function_call: None,
6183 },
6184 finish_reason: Some("tool_calls".to_string()),
6185 })
6186 }
6187
6188 fn router_with_stub_without_stream_usage(text: &str) -> Router {
6189 AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
6190 }
6191
6192 fn router_without_llm() -> Router {
6193 AxumServer::from_state(AppState::default()).build_router()
6194 }
6195
6196 fn router_with_failing_llm() -> Router {
6197 AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
6198 }
6199
6200 fn router_with_failing_llm_and_request_dump_dir(request_dump_dir: PathBuf) -> Router {
6201 AxumServer::from_state(
6202 AppState::default()
6203 .with_llm(Arc::new(FailingLlm::new()))
6204 .with_request_dump_dir(Some(request_dump_dir)),
6205 )
6206 .build_router()
6207 }
6208
6209 fn router_with_failing_llm_request_dump_and_profile(
6210 request_dump_dir: PathBuf,
6211 profile_jsonl: PathBuf,
6212 ) -> Router {
6213 AxumServer::from_state(
6214 AppState::default()
6215 .with_llm(Arc::new(FailingLlm::new()))
6216 .with_request_dump_dir(Some(request_dump_dir))
6217 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6218 .with_profile_jsonl(Some(profile_jsonl)),
6219 )
6220 .build_router()
6221 }
6222
6223 fn router_with_resource_exhausted_llm_and_request_dump_dir(
6224 request_dump_dir: PathBuf,
6225 ) -> Router {
6226 AxumServer::from_state(
6227 AppState::default()
6228 .with_llm(Arc::new(FailingLlm::resource_exhausted()))
6229 .with_request_dump_dir(Some(request_dump_dir)),
6230 )
6231 .build_router()
6232 }
6233
6234 fn router_with_stream_chunk_failing_llm() -> Router {
6235 AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
6236 }
6237
6238 fn router_with_stream_chunk_failing_llm_and_request_dump_dir(
6239 request_dump_dir: PathBuf,
6240 ) -> Router {
6241 AxumServer::from_state(
6242 AppState::default()
6243 .with_llm(Arc::new(FailingLlm::after_stream_start()))
6244 .with_request_dump_dir(Some(request_dump_dir)),
6245 )
6246 .build_router()
6247 }
6248
6249 fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
6250 let engine = Arc::new(CapturingLlm::new());
6251 let registry = ServedModelRegistry::try_new(
6252 "qwen3",
6253 ServedModelKind::Llm,
6254 vec![
6255 "qwen3".to_string(),
6256 "stub-model".to_string(),
6257 "served-alias".to_string(),
6258 ],
6259 vec![],
6260 )
6261 .unwrap();
6262 let router = AxumServer::from_llm(engine.clone())
6263 .with_served_model_registry(registry)
6264 .build_router();
6265 (router, engine)
6266 }
6267
6268 fn unique_request_dump_dir(test_name: &str) -> PathBuf {
6269 let path =
6270 std::env::temp_dir().join(format!("ferrum-server-{test_name}-{}", Uuid::new_v4()));
6271 fs::create_dir_all(&path).expect("create request dump dir");
6272 path
6273 }
6274
6275 fn unique_profile_jsonl(test_name: &str) -> PathBuf {
6276 std::env::temp_dir().join(format!(
6277 "ferrum-server-{test_name}-{}.jsonl",
6278 Uuid::new_v4()
6279 ))
6280 }
6281
6282 fn only_replay_bundle(root: &Path) -> PathBuf {
6283 let mut dirs = fs::read_dir(root)
6284 .expect("read request dump dir")
6285 .filter_map(|entry| {
6286 let path = entry.expect("dir entry").path();
6287 path.is_dir().then_some(path)
6288 })
6289 .collect::<Vec<_>>();
6290 dirs.sort();
6291 assert_eq!(
6292 dirs.len(),
6293 1,
6294 "expected exactly one replay bundle in {root:?}"
6295 );
6296 dirs.remove(0)
6297 }
6298
6299 fn read_json_file(path: impl AsRef<Path>) -> Value {
6300 let path = path.as_ref();
6301 let text = fs::read_to_string(path).unwrap_or_else(|err| {
6302 panic!("failed to read {}: {}", path.display(), err);
6303 });
6304 serde_json::from_str(&text).unwrap_or_else(|err| {
6305 panic!("failed to parse {}: {}", path.display(), err);
6306 })
6307 }
6308
6309 fn read_profile_events(path: &Path) -> Vec<Value> {
6310 let text = fs::read_to_string(path)
6311 .unwrap_or_else(|err| panic!("failed to read {}: {}", path.display(), err));
6312 text.lines()
6313 .filter(|line| !line.trim().is_empty())
6314 .map(|line| serde_json::from_str::<Value>(line).expect("profile event json"))
6315 .collect()
6316 }
6317
6318 fn assert_chat_failure_replay_bundle(
6319 root: &Path,
6320 expected_phase: &str,
6321 expected_error_kind: &str,
6322 expected_message: &str,
6323 ) {
6324 let bundle = only_replay_bundle(root);
6325 let request = read_json_file(bundle.join("request.json"));
6326 let request_id = request["request_id"]
6327 .as_str()
6328 .expect("request id")
6329 .to_string();
6330 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
6331 assert_eq!(bad_scan["request_id"], request_id);
6332 assert_eq!(bad_scan["failure_kind"], "error");
6333 assert_eq!(bad_scan["failure_phase"], expected_phase);
6334 assert_eq!(bad_scan["error_kind"], expected_error_kind);
6335
6336 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
6337 assert_eq!(diagnostics["request_id"], request_id);
6338 assert_eq!(diagnostics["failure_kind"], "error");
6339 assert_eq!(diagnostics["first_failure_event"]["phase"], expected_phase);
6340 assert_eq!(
6341 diagnostics["first_failure_event"]["error_kind"],
6342 expected_error_kind
6343 );
6344 assert_eq!(diagnostics["nearest_request_id"], request_id);
6345 assert!(diagnostics["log_excerpt"]
6346 .as_str()
6347 .expect("log excerpt")
6348 .contains(expected_message));
6349 assert!(bundle.join("replay.command.json").is_file());
6350 }
6351
6352 fn assert_chat_success_replay_bundle(
6353 root: &Path,
6354 expected_token_ids: &[u32],
6355 expected_finish_reason: &str,
6356 expected_output_text: &str,
6357 ) {
6358 let bundle = only_replay_bundle(root);
6359 let request = read_json_file(bundle.join("request.json"));
6360 let request_id = request["request_id"]
6361 .as_str()
6362 .expect("request id")
6363 .to_string();
6364 let prompt_tokens = read_json_file(bundle.join("prompt_token_ids.json"));
6365 assert_eq!(prompt_tokens["request_id"], request_id);
6366 assert_eq!(prompt_tokens["token_ids"], json!([101, 202, 303]));
6367 assert_eq!(prompt_tokens["token_count"], 3);
6368 assert!(prompt_tokens["unavailable_reason"].is_null());
6369 let output_tokens = read_json_file(bundle.join("output_token_ids.json"));
6370 assert_eq!(output_tokens["request_id"], request_id);
6371 assert_eq!(output_tokens["token_ids"], json!(expected_token_ids));
6372 assert_eq!(output_tokens["token_count"], expected_token_ids.len());
6373 assert_eq!(output_tokens["finish_reason"], expected_finish_reason);
6374 assert!(output_tokens["unavailable_reason"].is_null());
6375
6376 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
6377 assert_eq!(bad_scan["request_id"], request_id);
6378 assert_eq!(bad_scan["bad_output"], false);
6379 assert_eq!(bad_scan["failure_kind"], serde_json::Value::Null);
6380 assert_eq!(
6381 bad_scan["output_chars"],
6382 expected_output_text.chars().count()
6383 );
6384 assert_eq!(
6385 bad_scan["classified_output_sha256"],
6386 sha256_hex(expected_output_text.as_bytes())
6387 );
6388
6389 let output_text_bytes = fs::read(bundle.join("output_text.txt")).unwrap();
6390 assert_eq!(bad_scan["output_sha256"], sha256_hex(&output_text_bytes));
6391 let output_text = String::from_utf8(output_text_bytes).unwrap();
6392 assert!(output_text.contains("[redacted actual output]"));
6393 assert!(output_text.contains(&format!(
6394 "sha256={}",
6395 sha256_hex(expected_output_text.as_bytes())
6396 )));
6397 assert!(output_text.contains(&format!("chars={}", expected_output_text.chars().count())));
6398
6399 let replay_body = read_json_file(bundle.join("replay_body.json"));
6400 assert_eq!(replay_body["messages"][0]["role"], "user");
6401 assert_eq!(replay_body["messages"][0]["content"], "[redacted]");
6402 assert_eq!(replay_body["messages"][0]["content_redacted"], true);
6403
6404 let replay = read_json_file(bundle.join("replay.command.json"));
6405 assert_eq!(replay["requires_running_server"], true);
6406 let argv = replay["argv"].as_array().expect("replay argv");
6407 assert!(argv.iter().any(|item| item == "--data-binary"));
6408 assert!(argv.iter().any(|item| {
6409 item.as_str()
6410 .is_some_and(|value| value.starts_with('@') && value.ends_with("replay_body.json"))
6411 }));
6412 assert_eq!(replay["engine_replay"]["requires_http_server"], false);
6413 let engine_argv = replay["engine_replay"]["argv"]
6414 .as_array()
6415 .expect("engine replay argv");
6416 assert!(engine_argv.iter().any(|item| item == "replay-bundle"));
6417 }
6418
6419 fn router_with_capturing_llm_and_template(
6420 template: ModelChatTemplate,
6421 ) -> (Router, Arc<CapturingLlm>) {
6422 router_with_capturing_llm_and_template_default(template, None)
6423 }
6424
6425 fn router_with_capturing_llm_and_template_default(
6426 template: ModelChatTemplate,
6427 default_enable_thinking: Option<bool>,
6428 ) -> (Router, Arc<CapturingLlm>) {
6429 let engine = Arc::new(CapturingLlm::new());
6430 let registry = ServedModelRegistry::try_new(
6431 "qwen3",
6432 ServedModelKind::Llm,
6433 vec!["served-alias".to_string()],
6434 vec![],
6435 )
6436 .unwrap();
6437 let router = AxumServer::from_llm(engine.clone())
6438 .with_served_model_registry(registry)
6439 .with_prompt_template(Some(template))
6440 .with_default_enable_thinking(default_enable_thinking)
6441 .build_router();
6442 (router, engine)
6443 }
6444
6445 fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
6446 let engine = Arc::new(CapturingLlm::new());
6447 let router = AxumServer::from_llm(engine.clone())
6448 .with_lora_adapters(
6449 "qwen3",
6450 vec![LoraAdapterModel::new(
6451 "sql",
6452 "qwen3:sql",
6453 "/tmp/sql-adapter",
6454 )],
6455 )
6456 .unwrap()
6457 .build_router();
6458 (router, engine)
6459 }
6460
6461 fn router_with_stub_embed() -> Router {
6462 AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
6463 }
6464
6465 fn router_with_stub_transcribe() -> Router {
6466 AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
6467 }
6468
6469 fn router_with_stub_tts() -> Router {
6470 AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
6471 }
6472
6473 async fn post_json(app: Router, path: &str, body: Value) -> Response {
6474 app.oneshot(
6475 Request::builder()
6476 .method("POST")
6477 .uri(path)
6478 .header(header::CONTENT_TYPE, "application/json")
6479 .body(Body::from(body.to_string()))
6480 .expect("request"),
6481 )
6482 .await
6483 .expect("route response")
6484 }
6485
6486 async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
6487 app.oneshot(
6488 Request::builder()
6489 .method("POST")
6490 .uri(path)
6491 .header(header::CONTENT_TYPE, "application/json")
6492 .body(Body::from(body.to_string()))
6493 .expect("request"),
6494 )
6495 .await
6496 .expect("route response")
6497 }
6498
6499 async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
6500 app.oneshot(
6501 Request::builder()
6502 .method("POST")
6503 .uri(path)
6504 .header(
6505 header::CONTENT_TYPE,
6506 format!("multipart/form-data; boundary={boundary}"),
6507 )
6508 .body(Body::from(body.to_string()))
6509 .expect("request"),
6510 )
6511 .await
6512 .expect("route response")
6513 }
6514
6515 async fn get(app: Router, path: &str) -> Response {
6516 app.oneshot(
6517 Request::builder()
6518 .method("GET")
6519 .uri(path)
6520 .body(Body::empty())
6521 .expect("request"),
6522 )
6523 .await
6524 .expect("route response")
6525 }
6526
6527 async fn response_json(response: Response) -> Value {
6528 let bytes = to_bytes(response.into_body(), usize::MAX)
6529 .await
6530 .expect("body bytes");
6531 serde_json::from_slice(&bytes).expect("json body")
6532 }
6533
6534 async fn response_text(response: Response) -> String {
6535 let bytes = to_bytes(response.into_body(), usize::MAX)
6536 .await
6537 .expect("body bytes");
6538 String::from_utf8(bytes.to_vec()).expect("utf8 body")
6539 }
6540
6541 async fn response_bytes(response: Response) -> Vec<u8> {
6542 to_bytes(response.into_body(), usize::MAX)
6543 .await
6544 .expect("body bytes")
6545 .to_vec()
6546 }
6547
6548 async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
6549 let response = error.into_response();
6550 let status = response.status();
6551 (status, response_json(response).await)
6552 }
6553
6554 fn assert_openai_stream_error(body: &str, expected_message: &str) {
6555 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6556 assert!(
6557 body.contains("\"error\":{\"message\":\""),
6558 "stream failure should emit OpenAI error envelope: {body}"
6559 );
6560 assert!(
6561 body.contains(expected_message),
6562 "stream failure should include engine error message {expected_message:?}: {body}"
6563 );
6564 assert!(
6565 body.contains("\"type\":\"internal_server_error\""),
6566 "stream failure should use internal_server_error: {body}"
6567 );
6568 assert!(
6569 !body.contains("{\"error\":\""),
6570 "stream failure must not use legacy bare error payload: {body}"
6571 );
6572 }
6573
6574 fn chat_request(extra: Value) -> ChatCompletionsRequest {
6575 let mut value = json!({
6576 "model": "stub-model",
6577 "messages": [{"role": "user", "content": "hello"}],
6578 "max_tokens": 8
6579 });
6580 let obj = value.as_object_mut().unwrap();
6581 for (k, v) in extra.as_object().unwrap() {
6582 obj.insert(k.clone(), v.clone());
6583 }
6584 serde_json::from_value(value).expect("chat request")
6585 }
6586
6587 #[tokio::test]
6588 async fn responses_route_returns_sync_text_and_usage() {
6589 let response = post_json(
6590 router_with_stub("hello from ferrum"),
6591 "/v1/responses",
6592 json!({
6593 "model": "stub-model",
6594 "input": "hello",
6595 "store": false
6596 }),
6597 )
6598 .await;
6599 assert_eq!(response.status(), AxumStatusCode::OK);
6600 let body = response_json(response).await;
6601 assert_eq!(body["object"], "response");
6602 assert_eq!(body["status"], "completed");
6603 assert_eq!(body["store"], false);
6604 assert_eq!(body["output"][0]["type"], "message");
6605 assert_eq!(body["output"][0]["content"][0]["text"], "hello from ferrum");
6606 assert_eq!(body["usage"]["input_tokens"], 7);
6607 assert_eq!(body["usage"]["output_tokens"], 2);
6608 assert_eq!(body["usage"]["total_tokens"], 9);
6609 }
6610
6611 #[tokio::test]
6612 async fn responses_route_streams_ordered_text_events_once() {
6613 let response = post_json(
6614 router_with_stub_stream_chunks(&["he", "llo"]),
6615 "/v1/responses",
6616 json!({
6617 "model": "stub-model",
6618 "input": [{"role": "user", "content": "say hello"}],
6619 "stream": true
6620 }),
6621 )
6622 .await;
6623 assert_eq!(response.status(), AxumStatusCode::OK);
6624 let body = response_text(response).await;
6625 for event in [
6626 "response.created",
6627 "response.output_item.added",
6628 "response.output_text.delta",
6629 "response.output_text.done",
6630 "response.output_item.done",
6631 "response.completed",
6632 ] {
6633 assert!(
6634 body.contains(&format!("event: {event}")),
6635 "missing {event}: {body}"
6636 );
6637 }
6638 assert_eq!(
6639 body.matches("event: response.completed").count(),
6640 1,
6641 "completed must be emitted exactly once: {body}"
6642 );
6643 assert!(
6644 body.contains("\"delta\":\"he\""),
6645 "missing first delta: {body}"
6646 );
6647 assert!(
6648 body.contains("\"delta\":\"llo\""),
6649 "missing second delta: {body}"
6650 );
6651 assert!(body.contains("\"input_tokens\":5"), "missing usage: {body}");
6652 assert!(
6653 !body.contains("[DONE]"),
6654 "Responses streams do not use chat DONE: {body}"
6655 );
6656 }
6657
6658 #[tokio::test]
6659 async fn responses_route_supports_stateless_function_round_trip() {
6660 let tool = json!({
6661 "type": "function",
6662 "name": "weather",
6663 "description": "Get weather",
6664 "parameters": {
6665 "type": "object",
6666 "properties": {"city": {"type": "string"}},
6667 "required": ["city"]
6668 }
6669 });
6670 let first = post_json(
6671 router_with_stub_api_response("", weather_tool_api_response()),
6672 "/v1/responses",
6673 json!({
6674 "model": "stub-model",
6675 "input": "Use the weather tool",
6676 "tools": [tool.clone()],
6677 "tool_choice": "auto"
6678 }),
6679 )
6680 .await;
6681 assert_eq!(first.status(), AxumStatusCode::OK);
6682 let first_body = response_json(first).await;
6683 let call = first_body["output"][0].clone();
6684 assert_eq!(call["type"], "function_call");
6685 assert_eq!(call["call_id"], "call_1");
6686 assert_eq!(call["name"], "weather");
6687 assert_eq!(call["arguments"], "{\"city\":\"Paris\"}");
6688
6689 let second = post_json(
6690 router_with_stub("weather received"),
6691 "/v1/responses",
6692 json!({
6693 "model": "stub-model",
6694 "input": [
6695 {"role": "user", "content": "Use the weather tool"},
6696 call,
6697 {"type": "function_call_output", "call_id": "call_1", "output": "sunny"}
6698 ],
6699 "tools": [tool]
6700 }),
6701 )
6702 .await;
6703 assert_eq!(second.status(), AxumStatusCode::OK);
6704 let second_body = response_json(second).await;
6705 assert_eq!(
6706 second_body["output"][0]["content"][0]["text"],
6707 "weather received"
6708 );
6709 }
6710
6711 #[tokio::test]
6712 async fn responses_route_streams_function_call_events() {
6713 let response = post_json(
6714 router_with_stub_api_response("", weather_tool_api_response()),
6715 "/v1/responses",
6716 json!({
6717 "model": "stub-model",
6718 "input": "Use the weather tool",
6719 "stream": true,
6720 "tools": [{
6721 "type": "function",
6722 "name": "weather",
6723 "parameters": {"type": "object"}
6724 }]
6725 }),
6726 )
6727 .await;
6728 assert_eq!(response.status(), AxumStatusCode::OK);
6729 let body = response_text(response).await;
6730 assert!(
6731 body.contains("event: response.function_call_arguments.delta"),
6732 "missing function delta: {body}"
6733 );
6734 assert!(
6735 body.contains("event: response.function_call_arguments.done"),
6736 "missing function done: {body}"
6737 );
6738 assert!(
6739 body.contains("\"call_id\":\"call_1\""),
6740 "missing call id: {body}"
6741 );
6742 assert_eq!(body.matches("event: response.completed").count(), 1);
6743 }
6744
6745 #[tokio::test]
6746 async fn responses_route_rejects_state_and_non_function_tools() {
6747 for (extra, param) in [
6748 (json!({"store": true}), "store"),
6749 (
6750 json!({"previous_response_id": "resp_previous"}),
6751 "previous_response_id",
6752 ),
6753 (
6754 json!({"tools": [{"type": "mcp", "server_label": "docs"}]}),
6755 "tools[0].type",
6756 ),
6757 ] {
6758 let mut body = json!({"model": "stub-model", "input": "hello"});
6759 body.as_object_mut()
6760 .unwrap()
6761 .extend(extra.as_object().unwrap().clone());
6762 let response = post_json(router_with_stub("unused"), "/v1/responses", body).await;
6763 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6764 let error = response_json(response).await;
6765 assert_eq!(error["error"]["param"], param, "error: {error}");
6766 }
6767 }
6768
6769 #[tokio::test]
6770 async fn responses_mvp_keeps_chat_completions_route_working() {
6771 let response = post_json(
6772 router_with_stub("chat still works"),
6773 "/v1/chat/completions",
6774 json!({
6775 "model": "stub-model",
6776 "messages": [{"role": "user", "content": "hello"}]
6777 }),
6778 )
6779 .await;
6780 assert_eq!(response.status(), AxumStatusCode::OK);
6781 let body = response_json(response).await;
6782 assert_eq!(body["choices"][0]["message"]["content"], "chat still works");
6783 }
6784
6785 #[test]
6786 fn sanitized_chat_request_body_redacts_user_text_and_secret_metadata() {
6787 let request = chat_request(json!({
6788 "messages": [{"role": "user", "content": "private prompt"}],
6789 "metadata": {"api_key": "should-not-survive"},
6790 "stream": true
6791 }));
6792 let body = sanitized_chat_request_body(&request);
6793 assert_eq!(body["model"], "stub-model");
6794 assert_eq!(body["stream"], true);
6795 assert_eq!(body["messages"][0]["role"], "user");
6796 assert_eq!(body["messages"][0]["content"], "[redacted]");
6797 assert_eq!(body["messages"][0]["content_redacted"], true);
6798 assert_eq!(body["messages"][0]["content_chars"], 14);
6799 assert_eq!(body["metadata"]["api_key"], "[redacted]");
6800 }
6801
6802 #[test]
6803 fn admission_health_prefers_runtime_authority_over_preflight_estimate() {
6804 let engine_status = EngineStatus {
6805 is_ready: true,
6806 loaded_models: Vec::new(),
6807 active_requests: 2,
6808 queued_requests: 1,
6809 memory_usage: MemoryUsage {
6810 total_bytes: 0,
6811 used_bytes: 0,
6812 free_bytes: 0,
6813 gpu_memory_bytes: None,
6814 cpu_memory_bytes: None,
6815 cache_memory_bytes: 0,
6816 utilization_percent: 0.0,
6817 },
6818 uptime_seconds: 0,
6819 last_heartbeat: chrono::Utc::now(),
6820 version: "test".to_owned(),
6821 };
6822 let runtime = ferrum_types::ExecutorAdmissionSnapshot::new(
6823 ferrum_types::ExecutionResourceAuthority::PlanRuntime,
6824 ferrum_types::ExecutorAdmissionLimits::new(32, 4096).unwrap(),
6825 2,
6826 7,
6827 23,
6828 None,
6829 Some(3),
6830 )
6831 .unwrap();
6832 let admission = admission_health_json(
6833 &engine_status,
6834 &EngineMetrics::default(),
6835 &json!({
6836 "admission": {
6837 "effective_max_concurrent": 16,
6838 "scheduler_policy": "continuous"
6839 }
6840 }),
6841 Some(&runtime),
6842 None,
6843 );
6844
6845 assert_eq!(admission["source"], "runtime_executor");
6846 assert_eq!(admission["runtime_snapshot_available"], true);
6847 assert_eq!(admission["resource_authority"], "plan_runtime");
6848 assert_eq!(admission["effective_max_concurrent"], 32);
6849 assert_eq!(admission["maximum_active_sequences"], 32);
6850 assert_eq!(admission["maximum_scheduled_tokens"], 4096);
6851 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
6852 assert_eq!(admission["active_sequences"], 30);
6853 assert_eq!(admission["active_prefill"], 7);
6854 assert_eq!(admission["active_decode"], 23);
6855 assert!(admission["current_batch_size"].is_null());
6856 assert_eq!(admission["queue_depth"], 2);
6857 assert_eq!(admission["capacity_blocked_requests"], 3);
6858 }
6859
6860 #[test]
6861 fn admission_health_surfaces_runtime_contract_failure_without_preflight_fallback() {
6862 let engine_status = EngineStatus {
6863 is_ready: true,
6864 loaded_models: Vec::new(),
6865 active_requests: 32,
6866 queued_requests: 1,
6867 memory_usage: MemoryUsage {
6868 total_bytes: 0,
6869 used_bytes: 0,
6870 free_bytes: 0,
6871 gpu_memory_bytes: None,
6872 cpu_memory_bytes: None,
6873 cache_memory_bytes: 0,
6874 utilization_percent: 0.0,
6875 },
6876 uptime_seconds: 0,
6877 last_heartbeat: chrono::Utc::now(),
6878 version: "test".to_owned(),
6879 };
6880 let admission = admission_health_json(
6881 &engine_status,
6882 &EngineMetrics::default(),
6883 &json!({
6884 "admission": {
6885 "effective_max_concurrent": 16,
6886 "scheduler_policy": "continuous"
6887 }
6888 }),
6889 None,
6890 Some("active phase count exceeded the runtime ceiling"),
6891 );
6892
6893 assert_eq!(admission["source"], "runtime_error");
6894 assert_eq!(admission["runtime_snapshot_available"], false);
6895 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
6896 assert!(admission["effective_max_concurrent"].is_null());
6897 assert!(admission["queue_depth"].is_null());
6898 assert_eq!(
6899 admission["runtime_contract_error"],
6900 "active phase count exceeded the runtime ceiling"
6901 );
6902 }
6903
6904 #[tokio::test]
6905 async fn route_health_includes_runtime_config_snapshot() {
6906 let response = get(router_with_stub("ok"), "/health").await;
6907 assert_eq!(response.status(), AxumStatusCode::OK);
6908 let body = response_json(response).await;
6909 assert_eq!(body["status"], "healthy");
6910 assert!(body["config"]["entries"].is_array(), "body: {body}");
6911 assert_eq!(body["auto_config"]["schema_version"], 1);
6912 assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
6913 assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
6914 assert_eq!(body["admission"]["schema_version"], 2);
6915 assert!(body["admission"]["effective_max_concurrent"].is_number());
6916 assert!(body["admission"]["queue_depth"].is_number());
6917 assert!(body["admission"]["active_sequences"].is_number());
6918 assert!(body["admission"]["active_prefill"].is_null());
6919 assert!(body["admission"]["active_decode"].is_null());
6920 assert!(body["admission"]["current_batch_size"].is_null());
6921 assert!(body["admission"]["rejected_requests_total"].is_number());
6922 assert!(body["admission"]["failed_requests_total"].is_number());
6923 assert!(body["admission"]["completed_requests_total"].is_number());
6924 assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
6925 assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
6926 assert!(body["scheduler"]["scheduling_time_ms"].is_number());
6927 assert!(body["scheduler"]["model_execution_time_ms"].is_number());
6928 assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
6929 assert!(
6930 body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
6931 "body: {body}"
6932 );
6933 }
6934
6935 #[tokio::test]
6936 async fn route_metrics_includes_admission_counters() {
6937 let response = get(router_with_stub("ok"), "/metrics").await;
6938 assert_eq!(response.status(), AxumStatusCode::OK);
6939 let body = response_text(response).await;
6940 for metric in [
6941 "ferrum_admission_runtime_snapshot_available",
6942 "ferrum_admission_effective_max_concurrent",
6943 "ferrum_admission_queue_depth",
6944 "ferrum_admission_active_sequences",
6945 "ferrum_admission_rejected_requests_total",
6946 "ferrum_admission_failed_requests_total",
6947 "ferrum_admission_completed_requests_total",
6948 ] {
6949 assert!(body.contains(metric), "missing {metric}:\n{body}");
6950 }
6951 for unavailable_metric in [
6952 "ferrum_admission_maximum_active_sequences ",
6953 "ferrum_admission_maximum_scheduled_tokens ",
6954 "ferrum_admission_capacity_blocked_requests ",
6955 "ferrum_admission_active_prefill ",
6956 "ferrum_admission_active_decode ",
6957 "ferrum_admission_current_batch_size ",
6958 ] {
6959 assert!(
6960 !body.contains(unavailable_metric),
6961 "unknown metric was encoded as a real value: {unavailable_metric}\n{body}"
6962 );
6963 }
6964 }
6965
6966 #[tokio::test]
6967 async fn route_health_includes_engine_lora_metrics_snapshot() {
6968 let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
6969 "ok",
6970 json!({
6971 "enabled": true,
6972 "adapter_count": 1,
6973 "active_cache_bindings": 0,
6974 "projection_applications": 7,
6975 "position": "real-inference",
6976 "source": "test-lora",
6977 }),
6978 )))
6979 .with_lora_adapters(
6980 "stub-model",
6981 vec![LoraAdapterModel::new(
6982 "sql",
6983 "stub-model:sql",
6984 "/tmp/sql-adapter",
6985 )],
6986 )
6987 .unwrap()
6988 .build_router();
6989 let response = get(router, "/health").await;
6990 assert_eq!(response.status(), AxumStatusCode::OK);
6991 let body = response_json(response).await;
6992 assert_eq!(body["lora"]["enabled"], true);
6993 assert_eq!(body["lora"]["adapter_count"], 1);
6994 assert_eq!(body["lora"]["projection_applications"], 7);
6995 assert_eq!(body["lora"]["position"], "real-inference");
6996 assert_eq!(body["lora"]["source"], "test-lora");
6997 }
6998
6999 #[tokio::test]
7000 async fn route_models_lists_loaded_stub_model() {
7001 let response = get(router_with_stub("ok"), "/v1/models").await;
7002 assert_eq!(response.status(), AxumStatusCode::OK);
7003 let body = response_json(response).await;
7004 assert_eq!(body["object"], "list");
7005 let data = body["data"].as_array().expect("models data array");
7006 assert_eq!(data.len(), 1, "body: {body}");
7007 assert_eq!(data[0]["id"], "stub-model");
7008 assert_eq!(data[0]["object"], "model");
7009 assert_eq!(data[0]["owned_by"], "ferrum");
7010 assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
7011 assert_eq!(data[0]["modalities"], json!(["text"]));
7012 assert!(data[0]["permission"].as_array().unwrap().is_empty());
7013 assert!(data[0]["root"].is_null());
7014 assert!(data[0]["parent"].is_null());
7015 }
7016
7017 #[tokio::test]
7018 async fn route_chat_public_alias_maps_to_internal_model_and_is_echoed() {
7019 let engine = Arc::new(CapturingLlm::new());
7020 let registry = ServedModelRegistry::try_new(
7021 "qwen3",
7022 ServedModelKind::Llm,
7023 vec!["served-alias".to_string(), "secondary-alias".to_string()],
7024 vec![],
7025 )
7026 .unwrap();
7027 let router = AxumServer::from_llm(engine.clone())
7028 .with_served_model_registry(registry)
7029 .build_router();
7030 let response = post_json(
7031 router,
7032 "/v1/chat/completions",
7033 json!({
7034 "model": "secondary-alias",
7035 "messages": [{"role": "user", "content": "Say hi"}],
7036 "max_tokens": 8
7037 }),
7038 )
7039 .await;
7040
7041 assert_eq!(response.status(), AxumStatusCode::OK);
7042 let body = response_json(response).await;
7043 assert_eq!(body["model"], "secondary-alias");
7044 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
7045 }
7046
7047 #[tokio::test]
7048 async fn route_models_lists_public_aliases_without_internal_model_id() {
7049 let registry = ServedModelRegistry::try_new(
7050 "qwen3",
7051 ServedModelKind::Llm,
7052 vec!["served-alias".to_string(), "secondary-alias".to_string()],
7053 vec![],
7054 )
7055 .unwrap();
7056 let router = AxumServer::from_llm(Arc::new(CapturingLlm::new()))
7057 .with_served_model_registry(registry)
7058 .build_router();
7059 let body = response_json(get(router, "/v1/models").await).await;
7060 let ids = body["data"]
7061 .as_array()
7062 .unwrap()
7063 .iter()
7064 .map(|entry| entry["id"].as_str().unwrap())
7065 .collect::<Vec<_>>();
7066
7067 assert_eq!(ids, vec!["served-alias", "secondary-alias"]);
7068 assert!(!ids.contains(&"qwen3"));
7069 assert!(body["data"]
7070 .as_array()
7071 .unwrap()
7072 .iter()
7073 .all(|entry| entry["modalities"] == json!(["text"])));
7074 }
7075
7076 #[tokio::test]
7077 async fn route_models_lists_embedding_registry_capabilities() {
7078 let body = response_json(get(router_with_stub_embed(), "/v1/models").await).await;
7079 let data = body["data"].as_array().unwrap();
7080
7081 assert_eq!(data.len(), 1);
7082 assert_eq!(data[0]["id"], "stub-embed");
7083 assert_eq!(data[0]["modalities"], json!(["text", "image"]));
7084 }
7085
7086 #[tokio::test]
7087 async fn route_models_lists_startup_lora_adapters() {
7088 let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
7089 .with_lora_adapters(
7090 "stub-model",
7091 vec![LoraAdapterModel::new(
7092 "sql",
7093 "stub-model:sql",
7094 "/tmp/sql-adapter",
7095 )],
7096 )
7097 .unwrap()
7098 .build_router();
7099 let response = get(router, "/v1/models").await;
7100 assert_eq!(response.status(), AxumStatusCode::OK);
7101 let body = response_json(response).await;
7102 let data = body["data"].as_array().expect("models data array");
7103 let ids: Vec<_> = data
7104 .iter()
7105 .map(|item| item["id"].as_str().unwrap_or_default())
7106 .collect();
7107 assert!(ids.contains(&"stub-model"), "body: {body}");
7108 assert!(ids.contains(&"stub-model:sql"), "body: {body}");
7109 let adapter = data
7110 .iter()
7111 .find(|item| item["id"] == "stub-model:sql")
7112 .expect("adapter model");
7113 assert_eq!(adapter["root"], "stub-model");
7114 assert_eq!(adapter["parent"], "stub-model");
7115 assert_eq!(adapter["modalities"], json!(["text"]));
7116 }
7117
7118 #[tokio::test]
7119 async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
7120 let (router, engine) = router_with_capturing_lora_llm();
7121 let response = post_json(
7122 router,
7123 "/v1/chat/completions",
7124 json!({
7125 "model": "qwen3:sql",
7126 "messages": [{"role": "user", "content": "Say hi"}],
7127 "max_tokens": 8,
7128 "temperature": 0.0
7129 }),
7130 )
7131 .await;
7132 assert_eq!(response.status(), AxumStatusCode::OK);
7133 let body = response_json(response).await;
7134 assert_eq!(body["model"], "qwen3:sql");
7135 let captured = engine.last_request();
7136 assert_eq!(captured.model_id, ModelId::new("qwen3"));
7137 assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
7138 assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
7139 }
7140
7141 #[tokio::test]
7142 async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
7143 let (router, engine) = router_with_capturing_lora_llm();
7144 let response = post_json(
7145 router,
7146 "/v1/chat/completions",
7147 json!({
7148 "model": "qwen3",
7149 "messages": [{"role": "user", "content": "Say hi"}],
7150 "max_tokens": 8,
7151 "temperature": 0.0
7152 }),
7153 )
7154 .await;
7155 assert_eq!(response.status(), AxumStatusCode::OK);
7156 let captured = engine.last_request();
7157 assert_eq!(captured.model_id, ModelId::new("qwen3"));
7158 assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
7159 }
7160
7161 #[tokio::test]
7162 async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
7163 let (router, _) = router_with_capturing_lora_llm();
7164 let response = post_json(
7165 router,
7166 "/v1/chat/completions",
7167 json!({
7168 "model": "qwen3:missing",
7169 "messages": [{"role": "user", "content": "Say hi"}],
7170 "max_tokens": 8
7171 }),
7172 )
7173 .await;
7174 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7175 let body = response_json(response).await;
7176 assert_eq!(body["error"]["type"], "invalid_request_error");
7177 assert_eq!(body["error"]["param"], "model");
7178 assert!(
7179 body["error"]["message"]
7180 .as_str()
7181 .unwrap_or_default()
7182 .contains("unknown model"),
7183 "body: {body}"
7184 );
7185 }
7186
7187 #[tokio::test]
7188 async fn route_chat_unknown_served_model_returns_openai_model_error() {
7189 let engine = Arc::new(CapturingLlm::new());
7190 let router = AxumServer::from_llm(engine.clone()).build_router();
7191 let response = post_json(
7192 router,
7193 "/v1/chat/completions",
7194 json!({
7195 "model": "not-a-loaded-model",
7196 "messages": [{"role": "user", "content": "Say hi"}],
7197 "max_tokens": 8
7198 }),
7199 )
7200 .await;
7201 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7202 let body = response_json(response).await;
7203 assert_eq!(body["error"]["type"], "invalid_request_error");
7204 assert_eq!(body["error"]["param"], "model");
7205 assert!(
7206 body["error"]["message"]
7207 .as_str()
7208 .unwrap_or_default()
7209 .contains("unknown model"),
7210 "body: {body}"
7211 );
7212 assert!(!engine.has_captured_request());
7213 }
7214
7215 #[tokio::test]
7216 async fn route_models_without_engine_returns_empty_list() {
7217 let response = get(router_without_llm(), "/v1/models").await;
7218 assert_eq!(response.status(), AxumStatusCode::OK);
7219 let body = response_json(response).await;
7220 assert_eq!(body["object"], "list");
7221 assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
7222 }
7223
7224 #[tokio::test]
7225 async fn route_basic_chat_contract_uses_stub_engine() {
7226 let response = post_json(
7227 router_with_stub("hello"),
7228 "/v1/chat/completions",
7229 json!({
7230 "model": "stub-model",
7231 "messages": [{"role": "user", "content": "Say hi"}],
7232 "max_tokens": 8,
7233 "temperature": 0.0
7234 }),
7235 )
7236 .await;
7237 assert_eq!(response.status(), AxumStatusCode::OK);
7238 let body = response_json(response).await;
7239 assert_eq!(body["object"], "chat.completion");
7240 assert_eq!(body["choices"][0]["message"]["role"], "assistant");
7241 assert_eq!(body["choices"][0]["message"]["content"], "hello");
7242 assert_eq!(body["usage"]["prompt_tokens"], 7);
7243 assert_eq!(body["usage"]["completion_tokens"], 2);
7244 }
7245
7246 #[tokio::test]
7247 async fn route_chat_serializes_structured_tool_call_response() {
7248 let response = post_json(
7249 router_with_stub_api_response(
7250 "",
7251 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
7252 message: ferrum_types::ApiChatMessage {
7253 role: ferrum_types::ApiMessageRole::Assistant,
7254 content: String::new(),
7255 name: None,
7256 tool_calls: vec![ferrum_types::ApiToolCall {
7257 id: "call_1".to_string(),
7258 tool_type: "function".to_string(),
7259 function: ferrum_types::ApiFunctionCall {
7260 name: "weather".to_string(),
7261 arguments: "{\"city\":\"Paris\"}".to_string(),
7262 },
7263 }],
7264 tool_call_id: None,
7265 function_call: None,
7266 },
7267 finish_reason: Some("tool_calls".to_string()),
7268 }),
7269 ),
7270 "/v1/chat/completions",
7271 json!({
7272 "model": "stub-model",
7273 "messages": [{"role": "user", "content": "Use the weather tool."}],
7274 "tools": [{
7275 "type": "function",
7276 "function": {
7277 "name": "weather",
7278 "parameters": {
7279 "type": "object",
7280 "properties": {"city": {"type": "string"}},
7281 "required": ["city"]
7282 }
7283 }
7284 }],
7285 "tool_choice": "auto"
7286 }),
7287 )
7288 .await;
7289 assert_eq!(response.status(), AxumStatusCode::OK);
7290 let body = response_json(response).await;
7291 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7292 assert_eq!(
7293 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7294 "weather"
7295 );
7296 assert_eq!(
7297 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7298 "{\"city\":\"Paris\"}"
7299 );
7300 }
7301
7302 #[tokio::test]
7303 async fn route_chat_preserves_length_over_structured_tool_response() {
7304 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
7305 let response = post_json(
7306 router_with_stub_api_response_and_finish_reason(
7307 generated,
7308 weather_tool_api_response(),
7309 FinishReason::Length,
7310 ),
7311 "/v1/chat/completions",
7312 json!({
7313 "model": "stub-model",
7314 "messages": [{"role": "user", "content": "Use the weather tool."}],
7315 "tools": [{
7316 "type": "function",
7317 "function": {"name": "weather", "parameters": {"type": "object"}}
7318 }],
7319 "tool_choice": "auto"
7320 }),
7321 )
7322 .await;
7323 assert_eq!(response.status(), AxumStatusCode::OK);
7324 let body = response_json(response).await;
7325 assert_eq!(body["choices"][0]["finish_reason"], "length");
7326 assert_eq!(body["choices"][0]["message"]["content"], generated);
7327 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
7328 }
7329
7330 #[tokio::test]
7331 async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
7332 let response = post_json(
7333 router_with_stub(
7334 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
7335 ),
7336 "/v1/chat/completions",
7337 json!({
7338 "model": "stub-model",
7339 "messages": [{"role": "user", "content": "Use the weather tool."}],
7340 "tools": [{
7341 "type": "function",
7342 "function": {
7343 "name": "weather",
7344 "parameters": {
7345 "type": "object",
7346 "properties": {"city": {"type": "string"}},
7347 "required": ["city"]
7348 }
7349 }
7350 }],
7351 "tool_choice": "auto"
7352 }),
7353 )
7354 .await;
7355 assert_eq!(response.status(), AxumStatusCode::OK);
7356 let body = response_json(response).await;
7357 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7358 assert_eq!(body["choices"][0]["message"]["content"], "");
7359 assert_eq!(
7360 body["choices"][0]["message"]["tool_calls"][0]["id"],
7361 "call_1"
7362 );
7363 assert_eq!(
7364 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7365 "weather"
7366 );
7367 assert_eq!(
7368 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7369 "{\"city\":\"Paris\"}"
7370 );
7371 }
7372
7373 #[tokio::test]
7374 async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
7375 let response = post_json(
7376 router_with_stub(
7377 r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
7378 ),
7379 "/v1/chat/completions",
7380 json!({
7381 "model": "stub-model",
7382 "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
7383 "tools": [{
7384 "type": "function",
7385 "function": {
7386 "name": "get_weather",
7387 "parameters": {
7388 "type": "object",
7389 "properties": {
7390 "city": {"type": "string"},
7391 "unit": {"type": "string", "enum": ["c", "f"]}
7392 },
7393 "required": ["city"]
7394 }
7395 }
7396 }],
7397 "tool_choice": "auto"
7398 }),
7399 )
7400 .await;
7401 assert_eq!(response.status(), AxumStatusCode::OK);
7402 let body = response_json(response).await;
7403 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7404 assert_eq!(body["choices"][0]["message"]["content"], "");
7405 assert_eq!(
7406 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7407 "get_weather"
7408 );
7409 assert_eq!(
7410 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7411 "{\"city\":\"北京\",\"unit\":\"c\"}"
7412 );
7413 }
7414
7415 #[tokio::test]
7416 async fn route_chat_uses_template_tool_protocol_for_function_parameter_xml() {
7417 let template = ModelChatTemplate::new(
7418 "{% if tools %}<tools>{{ tools | tojson }}</tools>Use <tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% endif %}",
7419 "function-parameter-xml-template",
7420 );
7421 let response = post_json(
7422 router_with_stub_and_template(
7423 "<tool_call>\n<function=get_weather>\n<parameter=city>\n北京\n</parameter>\n<parameter=unit>\ncelsius\n</parameter>\n</function>\n</tool_call>",
7424 template,
7425 ),
7426 "/v1/chat/completions",
7427 json!({
7428 "model": "stub-model",
7429 "messages": [{"role": "user", "content": "请调用 get_weather 查询北京天气。"}],
7430 "tools": [{
7431 "type": "function",
7432 "function": {
7433 "name": "get_weather",
7434 "parameters": {
7435 "type": "object",
7436 "properties": {
7437 "city": {"type": "string"},
7438 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
7439 },
7440 "required": ["city"]
7441 }
7442 }
7443 }]
7444 }),
7445 )
7446 .await;
7447
7448 assert_eq!(response.status(), AxumStatusCode::OK);
7449 let body = response_json(response).await;
7450 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7451 assert_eq!(
7452 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7453 "get_weather"
7454 );
7455 assert_eq!(
7456 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7457 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
7458 );
7459 }
7460
7461 #[tokio::test]
7462 async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
7463 let response = post_json(
7464 router_with_stub(
7465 "kaza\n\
7466 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
7467 </think>\n\
7468 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
7469 {\"temperature\":25,\"condition\":\"晴\"}",
7470 ),
7471 "/v1/chat/completions",
7472 json!({
7473 "model": "stub-model",
7474 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
7475 "tools": [{
7476 "type": "function",
7477 "function": {
7478 "name": "get_weather",
7479 "parameters": {
7480 "type": "object",
7481 "properties": {
7482 "city": {"type": "string"},
7483 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
7484 },
7485 "required": ["city"]
7486 }
7487 }
7488 }]
7489 }),
7490 )
7491 .await;
7492 assert_eq!(response.status(), AxumStatusCode::OK);
7493 let body = response_json(response).await;
7494 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7495 assert_eq!(body["choices"][0]["message"]["content"], "");
7496 assert_eq!(
7497 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7498 "get_weather"
7499 );
7500 assert_eq!(
7501 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7502 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
7503 );
7504 }
7505
7506 #[tokio::test]
7507 async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
7508 let response = post_json(
7509 router_with_stub(
7510 "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
7511 </think>\n\
7512 {\"name\":\"get_weather\",\"arguments\":{}}",
7513 ),
7514 "/v1/chat/completions",
7515 json!({
7516 "model": "stub-model",
7517 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
7518 "tools": [{
7519 "type": "function",
7520 "function": {
7521 "name": "get_weather",
7522 "parameters": {
7523 "type": "object",
7524 "properties": {
7525 "city": {"type": "string"},
7526 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
7527 },
7528 "required": ["city"]
7529 }
7530 }
7531 }]
7532 }),
7533 )
7534 .await;
7535 assert_eq!(response.status(), AxumStatusCode::OK);
7536 let body = response_json(response).await;
7537 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7538 assert_eq!(
7539 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7540 "get_weather"
7541 );
7542 assert_eq!(
7543 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7544 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
7545 );
7546 }
7547
7548 #[tokio::test]
7549 async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
7550 let response = post_json(
7551 router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
7552 "/v1/chat/completions",
7553 json!({
7554 "model": "stub-model",
7555 "messages": [{"role": "user", "content": "Use the selected tool."}],
7556 "tools": [
7557 {
7558 "type": "function",
7559 "function": {"name": "weather", "parameters": {"type": "object"}}
7560 },
7561 {
7562 "type": "function",
7563 "function": {"name": "calendar", "parameters": {"type": "object"}}
7564 }
7565 ],
7566 "tool_choice": {
7567 "type": "function",
7568 "function": {"name": "weather"}
7569 }
7570 }),
7571 )
7572 .await;
7573 assert_eq!(response.status(), AxumStatusCode::OK);
7574 let body = response_json(response).await;
7575 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7576 assert_eq!(
7577 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7578 "weather"
7579 );
7580
7581 let response = post_json(
7582 router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
7583 "/v1/chat/completions",
7584 json!({
7585 "model": "stub-model",
7586 "messages": [{"role": "user", "content": "Use the selected tool."}],
7587 "tools": [
7588 {
7589 "type": "function",
7590 "function": {"name": "weather", "parameters": {"type": "object"}}
7591 },
7592 {
7593 "type": "function",
7594 "function": {"name": "calendar", "parameters": {"type": "object"}}
7595 }
7596 ],
7597 "tool_choice": {
7598 "type": "function",
7599 "function": {"name": "weather"}
7600 }
7601 }),
7602 )
7603 .await;
7604 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7605 let body = response_json(response).await;
7606 assert_eq!(body["error"]["param"], "tool_choice");
7607 assert_eq!(body["error"]["type"], "invalid_request_error");
7608 }
7609
7610 #[tokio::test]
7611 async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
7612 let response = post_json(
7613 router_with_stub(r#"{"city":"Paris"}"#),
7614 "/v1/chat/completions",
7615 json!({
7616 "model": "stub-model",
7617 "messages": [{"role": "user", "content": "Use the selected tool."}],
7618 "tools": [{
7619 "type": "function",
7620 "function": {
7621 "name": "weather",
7622 "parameters": {
7623 "type": "object",
7624 "properties": {"city": {"type": "string"}},
7625 "required": ["city"]
7626 }
7627 }
7628 }],
7629 "tool_choice": {
7630 "type": "function",
7631 "function": {"name": "weather"}
7632 }
7633 }),
7634 )
7635 .await;
7636 assert_eq!(response.status(), AxumStatusCode::OK);
7637 let body = response_json(response).await;
7638 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7639 assert_eq!(body["choices"][0]["message"]["content"], "");
7640 assert_eq!(
7641 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7642 "weather"
7643 );
7644 assert_eq!(
7645 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7646 "{\"city\":\"Paris\"}"
7647 );
7648 }
7649
7650 #[tokio::test]
7651 async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
7652 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
7653 let response = post_json(
7654 router_with_stub(generated),
7655 "/v1/chat/completions",
7656 json!({
7657 "model": "stub-model",
7658 "messages": [{"role": "user", "content": "Do not use tools."}],
7659 "tools": [{
7660 "type": "function",
7661 "function": {"name": "weather", "parameters": {"type": "object"}}
7662 }],
7663 "tool_choice": "none"
7664 }),
7665 )
7666 .await;
7667 assert_eq!(response.status(), AxumStatusCode::OK);
7668 let body = response_json(response).await;
7669 assert_eq!(body["choices"][0]["finish_reason"], "stop");
7670 assert_eq!(body["choices"][0]["message"]["content"], generated);
7671 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
7672 }
7673
7674 #[tokio::test]
7675 async fn route_chat_tool_choice_required_wraps_generated_arguments() {
7676 let response = post_json(
7677 router_with_stub(r#"{"city":"Paris"}"#),
7678 "/v1/chat/completions",
7679 json!({
7680 "model": "stub-model",
7681 "messages": [{"role": "user", "content": "Use a tool."}],
7682 "tools": [{
7683 "type": "function",
7684 "function": {
7685 "name": "weather",
7686 "parameters": {
7687 "type": "object",
7688 "properties": {"city": {"type": "string"}},
7689 "required": ["city"]
7690 }
7691 }
7692 }],
7693 "tool_choice": "required"
7694 }),
7695 )
7696 .await;
7697 assert_eq!(response.status(), AxumStatusCode::OK);
7698 let body = response_json(response).await;
7699 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7700 assert_eq!(body["choices"][0]["message"]["content"], "");
7701 assert_eq!(
7702 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7703 "weather"
7704 );
7705 assert_eq!(
7706 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7707 "{\"city\":\"Paris\"}"
7708 );
7709 }
7710
7711 fn required_tool_with_strict_response_format_request(stream: bool) -> Value {
7712 json!({
7713 "model": "stub-model",
7714 "messages": [{"role": "user", "content": "Use the weather tool."}],
7715 "stream": stream,
7716 "stream_options": stream.then_some(json!({"include_usage": true})),
7717 "tools": [{
7718 "type": "function",
7719 "function": {
7720 "name": "weather",
7721 "parameters": {
7722 "type": "object",
7723 "properties": {"city": {"type": "string", "const": "Paris"}},
7724 "required": ["city"],
7725 "additionalProperties": false
7726 }
7727 }
7728 }],
7729 "tool_choice": "required",
7730 "response_format": {
7731 "type": "json_schema",
7732 "json_schema": {
7733 "name": "content_answer",
7734 "strict": true,
7735 "schema": {
7736 "type": "object",
7737 "properties": {"answer": {"type": "string", "const": "IGNORED"}},
7738 "required": ["answer"],
7739 "additionalProperties": false
7740 }
7741 }
7742 }
7743 })
7744 }
7745
7746 #[tokio::test]
7747 async fn route_chat_required_tool_takes_priority_over_strict_response_format() {
7748 let response = post_json(
7749 router_with_stub(r#"{"city":"Paris"}"#),
7750 "/v1/chat/completions",
7751 required_tool_with_strict_response_format_request(false),
7752 )
7753 .await;
7754 assert_eq!(response.status(), AxumStatusCode::OK);
7755 let body = response_json(response).await;
7756 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
7757 assert_eq!(body["choices"][0]["message"]["content"], "");
7758 assert_eq!(
7759 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
7760 "weather"
7761 );
7762 assert_eq!(
7763 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
7764 r#"{"city":"Paris"}"#
7765 );
7766 }
7767
7768 #[tokio::test]
7769 async fn route_chat_required_tool_rejects_arguments_that_violate_const_schema() {
7770 let response = post_json(
7771 router_with_stub(r#"{"city":"London"}"#),
7772 "/v1/chat/completions",
7773 required_tool_with_strict_response_format_request(false),
7774 )
7775 .await;
7776 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
7777 let body = response_json(response).await;
7778 assert_eq!(body["error"]["type"], "internal_server_error");
7779 assert!(
7780 body["error"]["message"]
7781 .as_str()
7782 .is_some_and(|message| message.contains("did not satisfy its schema")),
7783 "body: {body}"
7784 );
7785 }
7786
7787 #[tokio::test]
7788 async fn route_streaming_required_tool_takes_priority_over_strict_response_format() {
7789 let response = post_json(
7790 router_with_stub(r#"{"city":"Paris"}"#),
7791 "/v1/chat/completions",
7792 required_tool_with_strict_response_format_request(true),
7793 )
7794 .await;
7795 assert_eq!(response.status(), AxumStatusCode::OK);
7796 let body = response_text(response).await;
7797 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
7798 assert!(
7799 body.contains(r#""finish_reason":"tool_calls""#),
7800 "tool priority must finish with tool_calls: {body}"
7801 );
7802 assert!(
7803 body.contains(r#""name":"weather""#)
7804 && body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
7805 "stream must carry the reconstructed tool call: {body}"
7806 );
7807 assert_eq!(
7808 body.matches(r#""usage":{"#).count(),
7809 1,
7810 "stream must carry exactly one usage row: {body}"
7811 );
7812 assert!(
7813 !body.contains("strict json_schema") && !body.contains("invalid JSON"),
7814 "dormant content schema must not reject a required tool call: {body}"
7815 );
7816 }
7817
7818 #[tokio::test]
7819 async fn dropping_buffered_http_response_drops_the_engine_stream() {
7820 let stream_dropped = Arc::new(Notify::new());
7821 let response = post_json(
7822 AxumServer::from_llm(Arc::new(StubLlm::with_pending_stream(Arc::clone(
7823 &stream_dropped,
7824 ))))
7825 .build_router(),
7826 "/v1/chat/completions",
7827 required_tool_with_strict_response_format_request(true),
7828 )
7829 .await;
7830 assert_eq!(response.status(), AxumStatusCode::OK);
7831
7832 drop(response);
7833 tokio::time::timeout(std::time::Duration::from_secs(1), stream_dropped.notified())
7834 .await
7835 .expect("client disconnect must stop a buffered structured stream promptly");
7836 }
7837
7838 #[tokio::test]
7839 async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
7840 let response = post_json(
7841 router_with_stub("plain answer"),
7842 "/v1/chat/completions",
7843 json!({
7844 "model": "stub-model",
7845 "messages": [{"role": "user", "content": "Use a tool."}],
7846 "tools": [{
7847 "type": "function",
7848 "function": {"name": "weather", "parameters": {"type": "object"}}
7849 }],
7850 "tool_choice": "required"
7851 }),
7852 )
7853 .await;
7854 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7855 let body = response_json(response).await;
7856 assert_eq!(body["error"]["type"], "invalid_request_error");
7857 assert_eq!(body["error"]["param"], "tool_choice");
7858 assert!(
7859 body["error"]["message"]
7860 .as_str()
7861 .is_some_and(|message| message.contains("required tool_choice")),
7862 "body: {body}"
7863 );
7864 }
7865
7866 #[tokio::test]
7867 async fn route_streaming_chat_serializes_generated_tool_call_delta() {
7868 let response = post_json(
7869 router_with_stub(
7870 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
7871 ),
7872 "/v1/chat/completions",
7873 json!({
7874 "model": "stub-model",
7875 "messages": [{"role": "user", "content": "Use the weather tool."}],
7876 "stream": true,
7877 "tools": [{
7878 "type": "function",
7879 "function": {
7880 "name": "weather",
7881 "parameters": {
7882 "type": "object",
7883 "properties": {"city": {"type": "string"}},
7884 "required": ["city"]
7885 }
7886 }
7887 }],
7888 "tool_choice": "auto"
7889 }),
7890 )
7891 .await;
7892 assert_eq!(response.status(), AxumStatusCode::OK);
7893 let body = response_text(response).await;
7894 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7895 assert!(
7896 body.contains(r#""finish_reason":"tool_calls""#),
7897 "stream should finish with tool_calls: {body}"
7898 );
7899 assert!(
7900 body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
7901 "stream should emit OpenAI tool_calls delta with index: {body}"
7902 );
7903 assert!(
7904 body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
7905 "tool arguments should be serialized as JSON string: {body}"
7906 );
7907 assert!(
7908 !body.contains(r#""content":"{\"tool_calls\""#),
7909 "raw tool-call JSON should not be streamed as assistant content: {body}"
7910 );
7911 }
7912
7913 #[tokio::test]
7914 async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
7915 let response = post_json(
7916 router_with_stub(
7917 r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
7918 ),
7919 "/v1/chat/completions",
7920 json!({
7921 "model": "stub-model",
7922 "messages": [{"role": "user", "content": "深圳天气?"}],
7923 "stream": true,
7924 "tools": [{
7925 "type": "function",
7926 "function": {
7927 "name": "get_weather",
7928 "parameters": {
7929 "type": "object",
7930 "properties": {
7931 "city": {"type": "string"},
7932 "unit": {"type": "string", "enum": ["c", "f"]}
7933 },
7934 "required": ["city"]
7935 }
7936 }
7937 }],
7938 "tool_choice": "auto"
7939 }),
7940 )
7941 .await;
7942 assert_eq!(response.status(), AxumStatusCode::OK);
7943 let body = response_text(response).await;
7944 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7945 assert!(
7946 body.contains(r#""finish_reason":"tool_calls""#),
7947 "stream should finish with tool_calls: {body}"
7948 );
7949 assert!(
7950 body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
7951 "stream should emit parsed Qwen3 function parameters as tool args: {body}"
7952 );
7953 assert!(
7954 !body.contains(r#""content":"{\"function\""#),
7955 "raw Qwen3 tool JSON should not leak as assistant content: {body}"
7956 );
7957 }
7958
7959 #[tokio::test]
7960 async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
7961 let request = |generated: &'static str| {
7962 post_json(
7963 router_with_stub(generated),
7964 "/v1/chat/completions",
7965 json!({
7966 "model": "stub-model",
7967 "messages": [{"role": "user", "content": "Use the selected tool."}],
7968 "stream": true,
7969 "tools": [
7970 {
7971 "type": "function",
7972 "function": {"name": "weather", "parameters": {"type": "object"}}
7973 },
7974 {
7975 "type": "function",
7976 "function": {"name": "calendar", "parameters": {"type": "object"}}
7977 }
7978 ],
7979 "tool_choice": {
7980 "type": "function",
7981 "function": {"name": "weather"}
7982 }
7983 }),
7984 )
7985 };
7986
7987 let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
7988 assert_eq!(response.status(), AxumStatusCode::OK);
7989 let body = response_text(response).await;
7990 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7991 assert!(
7992 body.contains(r#""finish_reason":"tool_calls""#),
7993 "selected tool should finish with tool_calls: {body}"
7994 );
7995 assert!(
7996 body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
7997 "selected tool should stream as tool_calls delta: {body}"
7998 );
7999
8000 let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
8001 assert_eq!(response.status(), AxumStatusCode::OK);
8002 let body = response_text(response).await;
8003 assert!(
8004 body.contains(
8005 r#""error":{"message":"model output did not satisfy required tool_choice""#
8006 ),
8007 "selected-tool stream should reject unselected tool output: {body}"
8008 );
8009 assert!(
8010 !body.contains(r#""finish_reason":"tool_calls""#),
8011 "unselected tool JSON must not become tool_calls: {body}"
8012 );
8013 }
8014
8015 #[tokio::test]
8016 async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
8017 let response = post_json(
8018 router_with_stub_api_response(
8019 "raw text that should not stream",
8020 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8021 message: ferrum_types::ApiChatMessage {
8022 role: ferrum_types::ApiMessageRole::Assistant,
8023 content: String::new(),
8024 name: None,
8025 tool_calls: vec![ferrum_types::ApiToolCall {
8026 id: "call_1".to_string(),
8027 tool_type: "function".to_string(),
8028 function: ferrum_types::ApiFunctionCall {
8029 name: "weather".to_string(),
8030 arguments: "{\"city\":\"Paris\"}".to_string(),
8031 },
8032 }],
8033 tool_call_id: None,
8034 function_call: None,
8035 },
8036 finish_reason: Some("tool_calls".to_string()),
8037 }),
8038 ),
8039 "/v1/chat/completions",
8040 json!({
8041 "model": "stub-model",
8042 "messages": [{"role": "user", "content": "Use the weather tool."}],
8043 "stream": true,
8044 "tools": [{
8045 "type": "function",
8046 "function": {"name": "weather", "parameters": {"type": "object"}}
8047 }],
8048 "tool_choice": "auto"
8049 }),
8050 )
8051 .await;
8052 assert_eq!(response.status(), AxumStatusCode::OK);
8053 let body = response_text(response).await;
8054 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8055 assert!(
8056 body.contains(r#""finish_reason":"tool_calls""#),
8057 "stream should finish with tool_calls: {body}"
8058 );
8059 assert!(
8060 body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
8061 "stream should emit tool_calls from chunk api_response: {body}"
8062 );
8063 assert!(
8064 !body.contains("raw text that should not stream"),
8065 "structured api_response should suppress raw generated text in tool-call stream: {body}"
8066 );
8067 }
8068
8069 #[tokio::test]
8070 async fn route_streaming_chat_preserves_length_over_structured_tool_response() {
8071 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
8072 let response = post_json(
8073 router_with_stub_api_response_and_finish_reason(
8074 generated,
8075 weather_tool_api_response(),
8076 FinishReason::Length,
8077 ),
8078 "/v1/chat/completions",
8079 json!({
8080 "model": "stub-model",
8081 "messages": [{"role": "user", "content": "Use the weather tool."}],
8082 "stream": true,
8083 "tools": [{
8084 "type": "function",
8085 "function": {"name": "weather", "parameters": {"type": "object"}}
8086 }],
8087 "tool_choice": "auto"
8088 }),
8089 )
8090 .await;
8091 assert_eq!(response.status(), AxumStatusCode::OK);
8092 let body = response_text(response).await;
8093 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8094 assert!(
8095 body.contains(r#""finish_reason":"length""#),
8096 "stream must preserve the engine terminal reason: {body}"
8097 );
8098 assert!(
8099 !body.contains(r#""finish_reason":"tool_calls""#),
8100 "length must not be relabeled as tool_calls: {body}"
8101 );
8102 }
8103
8104 #[tokio::test]
8105 async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
8106 let response = post_json(
8107 router_with_stub("plain answer"),
8108 "/v1/chat/completions",
8109 json!({
8110 "model": "stub-model",
8111 "messages": [{"role": "user", "content": "Use a tool."}],
8112 "stream": true,
8113 "tools": [{
8114 "type": "function",
8115 "function": {"name": "weather", "parameters": {"type": "object"}}
8116 }],
8117 "tool_choice": "required"
8118 }),
8119 )
8120 .await;
8121 assert_eq!(response.status(), AxumStatusCode::OK);
8122 let body = response_text(response).await;
8123 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
8124 assert!(
8125 body.contains(
8126 r#""error":{"message":"model output did not satisfy required tool_choice""#
8127 ),
8128 "stream should emit OpenAI error envelope: {body}"
8129 );
8130 assert!(
8131 body.contains(r#""type":"invalid_request_error""#),
8132 "stream should use invalid_request_error: {body}"
8133 );
8134 assert!(
8135 body.contains(r#""param":"tool_choice""#),
8136 "stream should include tool_choice param: {body}"
8137 );
8138 assert!(
8139 !body.contains(r#""content":"plain answer""#),
8140 "required stream must not leak invalid content before validation: {body}"
8141 );
8142 }
8143
8144 #[tokio::test]
8145 async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
8146 let response = post_json(
8147 router_with_stub("plain answer"),
8148 "/v1/chat/completions",
8149 json!({
8150 "model": "stub-model",
8151 "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
8152 "stream": true,
8153 "tools": [{
8154 "type": "function",
8155 "function": {"name": "weather", "parameters": {"type": "object"}}
8156 }],
8157 "tool_choice": "auto"
8158 }),
8159 )
8160 .await;
8161 assert_eq!(response.status(), AxumStatusCode::OK);
8162 let body = response_text(response).await;
8163 assert!(
8164 body.contains(r#""content":"plain answer""#),
8165 "plain content should still stream when no tool call is generated: {body}"
8166 );
8167 assert!(
8168 body.contains(r#""finish_reason":"stop""#),
8169 "plain content should keep normal finish reason: {body}"
8170 );
8171 assert!(
8172 !body.contains(r#""tool_calls""#),
8173 "fallback content should not synthesize tool_calls: {body}"
8174 );
8175 }
8176
8177 #[tokio::test]
8178 async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
8179 let response = post_json(
8180 router_with_stub(
8181 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
8182 ),
8183 "/v1/chat/completions",
8184 json!({
8185 "model": "stub-model",
8186 "messages": [{"role": "user", "content": "Use the weather function."}],
8187 "stream": true,
8188 "functions": [{
8189 "name": "weather",
8190 "parameters": {
8191 "type": "object",
8192 "properties": {"city": {"type": "string"}},
8193 "required": ["city"]
8194 }
8195 }],
8196 "function_call": "auto"
8197 }),
8198 )
8199 .await;
8200 assert_eq!(response.status(), AxumStatusCode::OK);
8201 let body = response_text(response).await;
8202 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8203 assert!(
8204 body.contains(r#""finish_reason":"function_call""#),
8205 "stream should finish with function_call: {body}"
8206 );
8207 assert!(
8208 body.contains(
8209 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
8210 ),
8211 "stream should emit OpenAI legacy function_call delta: {body}"
8212 );
8213 assert!(
8214 !body.contains(r#""content":"{\"function_call\""#),
8215 "raw function-call JSON should not be streamed as assistant content: {body}"
8216 );
8217 }
8218
8219 #[tokio::test]
8220 async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
8221 let request = |generated: &'static str| {
8222 post_json(
8223 router_with_stub(generated),
8224 "/v1/chat/completions",
8225 json!({
8226 "model": "stub-model",
8227 "messages": [{"role": "user", "content": "Use the selected function."}],
8228 "stream": true,
8229 "functions": [
8230 {"name": "weather", "parameters": {"type": "object"}},
8231 {"name": "calendar", "parameters": {"type": "object"}}
8232 ],
8233 "function_call": {"name": "weather"}
8234 }),
8235 )
8236 };
8237
8238 let response =
8239 request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
8240 assert_eq!(response.status(), AxumStatusCode::OK);
8241 let body = response_text(response).await;
8242 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8243 assert!(
8244 body.contains(r#""finish_reason":"function_call""#),
8245 "selected function should finish with function_call: {body}"
8246 );
8247 assert!(
8248 body.contains(
8249 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
8250 ),
8251 "selected function should stream as function_call delta: {body}"
8252 );
8253
8254 let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
8255 assert_eq!(response.status(), AxumStatusCode::OK);
8256 let body = response_text(response).await;
8257 assert!(
8258 body.contains(
8259 r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
8260 ),
8261 "unselected function JSON should stream as ordinary content: {body}"
8262 );
8263 assert!(
8264 body.contains(r#""finish_reason":"stop""#),
8265 "unselected function JSON should keep normal stop finish: {body}"
8266 );
8267 assert!(
8268 !body.contains(r#""finish_reason":"function_call""#),
8269 "unselected function JSON must not become function_call: {body}"
8270 );
8271 }
8272
8273 #[tokio::test]
8274 async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
8275 let response = post_json(
8276 router_with_stub(
8277 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
8278 ),
8279 "/v1/chat/completions",
8280 json!({
8281 "model": "stub-model",
8282 "messages": [{"role": "user", "content": "Use the weather function."}],
8283 "functions": [{
8284 "name": "weather",
8285 "parameters": {
8286 "type": "object",
8287 "properties": {"city": {"type": "string"}},
8288 "required": ["city"]
8289 }
8290 }],
8291 "function_call": "auto"
8292 }),
8293 )
8294 .await;
8295 assert_eq!(response.status(), AxumStatusCode::OK);
8296 let body = response_json(response).await;
8297 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
8298 assert_eq!(body["choices"][0]["message"]["content"], "");
8299 assert_eq!(
8300 body["choices"][0]["message"]["function_call"]["name"],
8301 "weather"
8302 );
8303 assert_eq!(
8304 body["choices"][0]["message"]["function_call"]["arguments"],
8305 "{\"city\":\"Paris\"}"
8306 );
8307 }
8308
8309 #[tokio::test]
8310 async fn route_chat_serializes_legacy_function_call_response() {
8311 let response = post_json(
8312 router_with_stub_api_response(
8313 "",
8314 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8315 message: ferrum_types::ApiChatMessage {
8316 role: ferrum_types::ApiMessageRole::Assistant,
8317 content: String::new(),
8318 name: None,
8319 tool_calls: vec![],
8320 tool_call_id: None,
8321 function_call: Some(ferrum_types::ApiFunctionCall {
8322 name: "weather".to_string(),
8323 arguments: "{\"city\":\"Paris\"}".to_string(),
8324 }),
8325 },
8326 finish_reason: Some("function_call".to_string()),
8327 }),
8328 ),
8329 "/v1/chat/completions",
8330 json!({
8331 "model": "stub-model",
8332 "messages": [{"role": "user", "content": "Use the weather function."}],
8333 "functions": [{
8334 "name": "weather",
8335 "parameters": {
8336 "type": "object",
8337 "properties": {"city": {"type": "string"}},
8338 "required": ["city"]
8339 }
8340 }],
8341 "function_call": "auto"
8342 }),
8343 )
8344 .await;
8345 assert_eq!(response.status(), AxumStatusCode::OK);
8346 let body = response_json(response).await;
8347 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
8348 assert_eq!(
8349 body["choices"][0]["message"]["function_call"]["name"],
8350 "weather"
8351 );
8352 assert_eq!(
8353 body["choices"][0]["message"]["function_call"]["arguments"],
8354 "{\"city\":\"Paris\"}"
8355 );
8356 }
8357
8358 #[tokio::test]
8359 async fn route_streaming_chat_include_usage_contract() {
8360 let response = post_json(
8361 router_with_stub("ok"),
8362 "/v1/chat/completions",
8363 json!({
8364 "model": "stub-model",
8365 "messages": [{"role": "user", "content": "Say ok"}],
8366 "stream": true,
8367 "stream_options": {"include_usage": true}
8368 }),
8369 )
8370 .await;
8371 assert_eq!(response.status(), AxumStatusCode::OK);
8372 let body = response_text(response).await;
8373 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
8374 assert!(
8375 body.contains("\"object\":\"chat.completion.chunk\""),
8376 "missing chat chunk: {body}"
8377 );
8378 assert!(
8379 body.contains("\"usage\":{\"prompt_tokens\""),
8380 "missing final usage chunk: {body}"
8381 );
8382 assert!(
8383 body.contains("\"choices\":[],\"usage\""),
8384 "usage should be emitted as a separate chunk: {body}"
8385 );
8386 assert!(
8387 body.contains("\"prompt_tokens\":5"),
8388 "stream usage should come from engine token usage: {body}"
8389 );
8390 }
8391
8392 #[tokio::test]
8393 async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
8394 let response = post_json(
8395 router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
8396 "/v1/chat/completions",
8397 json!({
8398 "model": "stub-model",
8399 "messages": [{"role": "user", "content": "Say hello"}],
8400 "max_tokens": 2,
8401 "stream": true,
8402 "stream_options": {"include_usage": true}
8403 }),
8404 )
8405 .await;
8406 assert_eq!(response.status(), AxumStatusCode::OK);
8407 let body = response_text(response).await;
8408 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
8409 assert!(
8410 body.contains("\"content\":\"he\""),
8411 "missing first chunk: {body}"
8412 );
8413 assert!(
8414 body.contains("\"content\":\"llo\""),
8415 "missing second chunk: {body}"
8416 );
8417 assert!(
8418 body.contains("\"choices\":[],\"usage\""),
8419 "missing separate usage chunk from final engine chunk: {body}"
8420 );
8421 assert!(
8422 body.contains("\"prompt_tokens\":5"),
8423 "stream usage should come from engine final usage: {body}"
8424 );
8425 }
8426
8427 #[tokio::test]
8428 async fn route_rejects_multimodal_content_with_400() {
8429 for content in [
8430 json!([{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}]),
8431 json!([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]),
8432 json!([{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}]),
8433 json!([
8434 {"type": "text", "text": "describe this"},
8435 {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
8436 ]),
8437 ] {
8438 let response = post_json(
8439 router_with_stub("unused"),
8440 "/v1/chat/completions",
8441 json!({
8442 "model": "stub-model",
8443 "messages": [{"role": "user", "content": content}]
8444 }),
8445 )
8446 .await;
8447 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8448 let body = response_json(response).await;
8449 assert_eq!(body["error"]["type"], "invalid_request_error");
8450 let message = body["error"]["message"].as_str().unwrap();
8451 assert!(message.contains("invalid chat completions request"));
8452 assert!(
8453 message.contains("unsupported message content part type"),
8454 "body: {body}"
8455 );
8456 }
8457 }
8458
8459 #[tokio::test]
8460 async fn route_rejects_non_object_stream_options() {
8461 for stream_options in [json!([]), json!("yes"), json!(42), json!(true)] {
8462 let response = post_json(
8463 router_with_stub("unused"),
8464 "/v1/chat/completions",
8465 json!({
8466 "model": "stub-model",
8467 "messages": [{"role": "user", "content": "hello"}],
8468 "stream": true,
8469 "stream_options": stream_options
8470 }),
8471 )
8472 .await;
8473 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8474 let body = response_json(response).await;
8475 assert_eq!(body["error"]["type"], "invalid_request_error");
8476 assert!(
8477 body["error"]["message"]
8478 .as_str()
8479 .unwrap_or_default()
8480 .contains("stream_options must be a JSON object"),
8481 "body: {body}"
8482 );
8483 }
8484 }
8485
8486 #[tokio::test]
8487 async fn route_accepts_text_only_content_array() {
8488 let response = post_json(
8489 router_with_stub("ok"),
8490 "/v1/chat/completions",
8491 json!({
8492 "model": "stub-model",
8493 "messages": [{
8494 "role": "user",
8495 "content": [
8496 {"type": "text", "text": "say"},
8497 {"type": "text", "text": "ok"}
8498 ]
8499 }]
8500 }),
8501 )
8502 .await;
8503 assert_eq!(response.status(), AxumStatusCode::OK);
8504 let body = response_json(response).await;
8505 assert_eq!(body["choices"][0]["message"]["content"], "ok");
8506 }
8507
8508 #[tokio::test]
8509 async fn route_chat_invalid_json_maps_to_openai_error() {
8510 let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
8511 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8512 let body = response_json(response).await;
8513 assert_eq!(body["error"]["type"], "invalid_request_error");
8514 assert_eq!(body["error"]["param"], Value::Null);
8515 assert!(body["error"]["message"]
8516 .as_str()
8517 .unwrap()
8518 .contains("invalid chat completions request"));
8519 }
8520
8521 #[tokio::test]
8522 async fn route_rejects_logit_bias_with_openai_error_param() {
8523 let response = post_json(
8524 router_with_stub("unused"),
8525 "/v1/chat/completions",
8526 json!({
8527 "model": "stub-model",
8528 "messages": [{"role": "user", "content": "hello"}],
8529 "logit_bias": {"1": 42.0}
8530 }),
8531 )
8532 .await;
8533 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8534 let body = response_json(response).await;
8535 assert_eq!(body["error"]["type"], "invalid_request_error");
8536 assert_eq!(body["error"]["param"], "logit_bias");
8537 }
8538
8539 #[tokio::test]
8540 async fn route_tool_request_reaches_engine_structured_boundary() {
8541 let (router, engine) = router_with_capturing_llm();
8542 let response = post_json(
8543 router,
8544 "/v1/chat/completions",
8545 json!({
8546 "model": "qwen3",
8547 "messages": [
8548 {"role": "user", "content": "Use the weather tool."},
8549 {
8550 "role": "assistant",
8551 "content": null,
8552 "tool_calls": [{
8553 "id": "call_1",
8554 "type": "function",
8555 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
8556 }]
8557 },
8558 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
8559 ],
8560 "tools": [{
8561 "type": "function",
8562 "function": {
8563 "name": "weather",
8564 "description": "Get weather",
8565 "parameters": {
8566 "type": "object",
8567 "properties": {"city": {"type": "string"}},
8568 "required": ["city"]
8569 }
8570 }
8571 }],
8572 "tool_choice": "auto",
8573 "functions": [{
8574 "name": "legacy_weather",
8575 "parameters": {"type": "object", "properties": {}}
8576 }],
8577 "function_call": "auto"
8578 }),
8579 )
8580 .await;
8581 assert_eq!(response.status(), AxumStatusCode::OK);
8582
8583 let request = engine.last_request();
8584 assert!(request.prompt.contains("\"tools\":[{"));
8585 assert!(request.prompt.contains("\"type\":\"function\""));
8586 assert!(request.prompt.contains("\"name\":\"weather\""));
8587 assert!(request.prompt.contains("<|im_start|>assistant\n{"));
8588 assert!(request.prompt.contains("\"tool_calls\":[{"));
8589 assert!(request.prompt.contains("\"id\":\"call_1\""));
8590 assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
8591 assert_eq!(
8592 request.metadata["openai_tools"][0]["function"]["name"],
8593 "weather"
8594 );
8595 assert_eq!(request.metadata["openai_tool_choice"], "auto");
8596 assert_eq!(
8597 request.metadata["openai_legacy_functions"][0]["name"],
8598 "legacy_weather"
8599 );
8600 assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
8601 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
8602 panic!("expected structured chat api_request");
8603 };
8604 assert_eq!(api.messages.len(), 3);
8605 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
8606 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
8607 assert_eq!(api.tools[0].function.name, "weather");
8608 assert_eq!(api.legacy_functions[0].name, "legacy_weather");
8609 assert_eq!(
8610 api.messages[1].tool_calls[0].function.arguments,
8611 "{\"city\":\"Paris\"}"
8612 );
8613 }
8614
8615 #[tokio::test]
8616 async fn route_tool_request_prefers_model_chat_template() {
8617 let template = ModelChatTemplate::new(
8618 "{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% if message.tool_calls %}{% for tool_call in message.tool_calls %}<tool_call>{{ tool_call.function.name }}:{{ tool_call.function.arguments }}</tool_call>{% endfor %}{% endif %}{% if message.tool_call_id %}<tool_response id=\"{{ message.tool_call_id }}\">{{ message.content }}</tool_response>{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
8619 "tool-template",
8620 );
8621 let (router, engine) = router_with_capturing_llm_and_template(template);
8622 let response = post_json(
8623 router,
8624 "/v1/chat/completions",
8625 json!({
8626 "model": "served-alias",
8627 "messages": [
8628 {"role": "user", "content": "Use the weather tool."},
8629 {
8630 "role": "assistant",
8631 "content": null,
8632 "tool_calls": [{
8633 "id": "call_1",
8634 "type": "function",
8635 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
8636 }]
8637 },
8638 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
8639 ],
8640 "tools": [{
8641 "type": "function",
8642 "function": {
8643 "name": "weather",
8644 "description": "Get weather",
8645 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
8646 }
8647 }],
8648 "tool_choice": "auto"
8649 }),
8650 )
8651 .await;
8652 assert_eq!(response.status(), AxumStatusCode::OK);
8653
8654 let request = engine.last_request();
8655 assert!(request.prompt.contains("<tools>weather</tools>"));
8656 assert!(
8657 request.prompt.contains("<tool_call>weather:"),
8658 "{}",
8659 request.prompt
8660 );
8661 assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
8662 assert!(request.prompt.contains("Paris"), "{}", request.prompt);
8663 assert!(request
8664 .prompt
8665 .contains("<tool_response id=\"call_1\">sunny</tool_response>"));
8666 assert!(
8667 !request.prompt.contains("<|assistant|>"),
8668 "model-template tool prompt should not use generic fallback: {}",
8669 request.prompt
8670 );
8671 assert!(
8672 !request.prompt.contains("When a tool is needed"),
8673 "model-template tool prompt should not inject fallback tool instructions: {}",
8674 request.prompt
8675 );
8676 }
8677
8678 #[tokio::test]
8679 async fn chat_omitted_output_budget_uses_auto_ceiling() {
8680 let (router, engine) = router_with_capturing_llm();
8681 let response = post_json(
8682 router,
8683 "/v1/chat/completions",
8684 json!({
8685 "model": "stub-model",
8686 "messages": [{"role": "user", "content": "hello"}]
8687 }),
8688 )
8689 .await;
8690 assert_eq!(response.status(), AxumStatusCode::OK);
8691
8692 let request = engine.last_request();
8693 assert_eq!(request.sampling_params.max_tokens, 4096);
8694 assert_eq!(
8695 request.metadata.get(DEFAULT_MAX_TOKENS_METADATA_KEY),
8696 Some(&serde_json::json!(true))
8697 );
8698 }
8699
8700 #[tokio::test]
8701 async fn chat_accepts_stop_string_and_max_completion_tokens() {
8702 let (router, engine) = router_with_capturing_llm();
8703 let response = post_json(
8704 router,
8705 "/v1/chat/completions",
8706 json!({
8707 "model": "stub-model",
8708 "messages": [{"role": "user", "content": "hello"}],
8709 "max_tokens": 99,
8710 "max_completion_tokens": 3,
8711 "stop": "<END>"
8712 }),
8713 )
8714 .await;
8715 assert_eq!(response.status(), AxumStatusCode::OK);
8716
8717 let request = engine.last_request();
8718 let defaults = default_chat_sampling_params();
8719 assert_eq!(request.sampling_params.max_tokens, 3);
8720 assert!(!request
8721 .metadata
8722 .contains_key(DEFAULT_MAX_TOKENS_METADATA_KEY));
8723 assert_eq!(request.sampling_params.temperature, defaults.temperature);
8724 assert_eq!(
8725 request.sampling_params.repetition_penalty,
8726 defaults.repetition_penalty
8727 );
8728 assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
8729 }
8730
8731 #[tokio::test]
8732 async fn chat_maps_vllm_sampling_extensions_without_hidden_defaults() {
8733 let (router, engine) = router_with_capturing_llm();
8734 let response = post_json(
8735 router,
8736 "/v1/chat/completions",
8737 json!({
8738 "model": "stub-model",
8739 "messages": [{"role": "user", "content": "hello"}],
8740 "top_k": 20,
8741 "min_p": 0.05,
8742 "repetition_penalty": 1.25
8743 }),
8744 )
8745 .await;
8746 assert_eq!(response.status(), AxumStatusCode::OK);
8747
8748 let request = engine.last_request();
8749 assert_eq!(request.sampling_params.top_k, Some(20));
8750 assert_eq!(request.sampling_params.min_p, Some(0.05));
8751 assert_eq!(request.sampling_params.repetition_penalty, 1.25);
8752 }
8753
8754 #[tokio::test]
8755 async fn chat_normalizes_disabled_sampling_extensions_and_rejects_invalid_ranges() {
8756 let (router, engine) = router_with_capturing_llm();
8757 let response = post_json(
8758 router,
8759 "/v1/chat/completions",
8760 json!({
8761 "model": "stub-model",
8762 "messages": [{"role": "user", "content": "hello"}],
8763 "top_k": -1,
8764 "min_p": 0.0,
8765 "repetition_penalty": 1.0
8766 }),
8767 )
8768 .await;
8769 assert_eq!(response.status(), AxumStatusCode::OK);
8770 let request = engine.last_request();
8771 assert_eq!(request.sampling_params.top_k, None);
8772 assert_eq!(request.sampling_params.min_p, None);
8773 assert_eq!(request.sampling_params.repetition_penalty, 1.0);
8774
8775 for (field, value) in [
8776 ("top_k", json!(-2)),
8777 ("min_p", json!(1.01)),
8778 ("repetition_penalty", json!(0.0)),
8779 ("presence_penalty", json!(2.01)),
8780 ("frequency_penalty", json!(-2.01)),
8781 ] {
8782 let (router, _) = router_with_capturing_llm();
8783 let response = post_json(
8784 router,
8785 "/v1/chat/completions",
8786 json!({
8787 "model": "stub-model",
8788 "messages": [{"role": "user", "content": "hello"}],
8789 (field): value
8790 }),
8791 )
8792 .await;
8793 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST, "{field}");
8794 let body = response_json(response).await;
8795 assert_eq!(body["error"]["param"], field);
8796 }
8797 }
8798
8799 #[tokio::test]
8800 async fn chat_request_forbids_initial_think_close_token() {
8801 let engine = Arc::new(CapturingLlm::new());
8802 let router = AxumServer::from_llm(engine.clone()).build_router();
8803 let response = post_json(
8804 router,
8805 "/v1/chat/completions",
8806 json!({
8807 "model": "qwen3",
8808 "messages": [{"role": "user", "content": "hello"}]
8809 }),
8810 )
8811 .await;
8812 assert_eq!(response.status(), AxumStatusCode::OK);
8813
8814 let request = engine.last_request();
8815 assert_eq!(
8816 request
8817 .metadata
8818 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
8819 Some(&serde_json::json!([THINK_END_TAG]))
8820 );
8821 }
8822
8823 #[tokio::test]
8824 async fn omitted_enable_thinking_preserves_model_template_default() {
8825 let template = ModelChatTemplate::new(
8826 "{% for message in messages %}{{ '<|im_start|>' ~ message.role ~ '\n' ~ message.content ~ '<|im_end|>\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% if enable_thinking is defined and enable_thinking is false %}{{ '<think>\n\n</think>\n\n' }}{% else %}{{ '<think>\n' }}{% endif %}{% endif %}",
8827 "test-template",
8828 );
8829 let (router, engine) = router_with_capturing_llm_and_template(template);
8830 let response = post_json(
8831 router,
8832 "/v1/chat/completions",
8833 json!({
8834 "model": "served-alias",
8835 "messages": [{"role": "user", "content": "hello"}]
8836 }),
8837 )
8838 .await;
8839 assert_eq!(response.status(), AxumStatusCode::OK);
8840
8841 let request = engine.last_request();
8842 assert!(request.prompt.ends_with("<|im_start|>assistant\n<think>\n"));
8843 assert!(!request
8844 .metadata
8845 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
8846 }
8847
8848 #[tokio::test]
8849 async fn server_thinking_default_applies_but_request_override_wins() {
8850 let template = ModelChatTemplate::new(
8851 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
8852 "test-template",
8853 );
8854 let (router, engine) =
8855 router_with_capturing_llm_and_template_default(template, Some(false));
8856
8857 let response = post_json(
8858 router.clone(),
8859 "/v1/chat/completions",
8860 json!({
8861 "model": "served-alias",
8862 "messages": [{"role": "user", "content": "hello"}]
8863 }),
8864 )
8865 .await;
8866 assert_eq!(response.status(), AxumStatusCode::OK);
8867 let request = engine.last_request();
8868 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
8869 assert_eq!(
8870 request
8871 .metadata
8872 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
8873 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
8874 );
8875
8876 let response = post_json(
8877 router,
8878 "/v1/chat/completions",
8879 json!({
8880 "model": "served-alias",
8881 "messages": [{"role": "user", "content": "hello"}],
8882 "chat_template_kwargs": {"enable_thinking": true}
8883 }),
8884 )
8885 .await;
8886 assert_eq!(response.status(), AxumStatusCode::OK);
8887 let request = engine.last_request();
8888 assert_eq!(request.prompt, "<assistant><think>\n");
8889 assert!(!request
8890 .metadata
8891 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
8892 }
8893
8894 #[tokio::test]
8895 async fn chat_template_enable_thinking_true_overrides_default() {
8896 let template = ModelChatTemplate::new(
8897 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
8898 "test-template",
8899 );
8900 let (router, engine) = router_with_capturing_llm_and_template(template);
8901 let response = post_json(
8902 router,
8903 "/v1/chat/completions",
8904 json!({
8905 "model": "served-alias",
8906 "messages": [{"role": "user", "content": "hello"}],
8907 "chat_template_kwargs": {"enable_thinking": true}
8908 }),
8909 )
8910 .await;
8911 assert_eq!(response.status(), AxumStatusCode::OK);
8912
8913 let request = engine.last_request();
8914 assert_eq!(request.prompt, "<assistant><think>\n");
8915 assert!(!request
8916 .metadata
8917 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
8918 }
8919
8920 #[tokio::test]
8921 async fn chat_template_enable_thinking_false_is_a_hard_override() {
8922 let template = ModelChatTemplate::new(
8923 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
8924 "test-template",
8925 );
8926 let (router, engine) = router_with_capturing_llm_and_template(template);
8927 let response = post_json(
8928 router,
8929 "/v1/chat/completions",
8930 json!({
8931 "model": "served-alias",
8932 "messages": [{"role": "user", "content": "hello"}],
8933 "chat_template_kwargs": {"enable_thinking": false}
8934 }),
8935 )
8936 .await;
8937 assert_eq!(response.status(), AxumStatusCode::OK);
8938
8939 let request = engine.last_request();
8940 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
8941 assert_eq!(
8942 request
8943 .metadata
8944 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
8945 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
8946 );
8947 }
8948
8949 #[tokio::test]
8950 async fn chat_template_enable_thinking_rejects_non_bool() {
8951 let template = ModelChatTemplate::new(
8952 "{% if add_generation_prompt %}<assistant>{% endif %}",
8953 "test-template",
8954 );
8955 let (router, _) = router_with_capturing_llm_and_template(template);
8956 let response = post_json(
8957 router,
8958 "/v1/chat/completions",
8959 json!({
8960 "model": "served-alias",
8961 "messages": [{"role": "user", "content": "hello"}],
8962 "chat_template_kwargs": {"enable_thinking": "false"}
8963 }),
8964 )
8965 .await;
8966 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8967 let body = response_json(response).await;
8968 assert_eq!(body["error"]["type"], "invalid_request_error");
8969 assert!(body["error"]["message"]
8970 .as_str()
8971 .unwrap_or_default()
8972 .contains("chat_template_kwargs.enable_thinking must be a boolean"));
8973 }
8974
8975 #[tokio::test]
8976 async fn stop_string_strips_chat_and_completion_suffixes() {
8977 let chat = post_json(
8978 router_with_stub("hello<END>"),
8979 "/v1/chat/completions",
8980 json!({
8981 "model": "stub-model",
8982 "messages": [{"role": "user", "content": "hello"}],
8983 "stop": "<END>"
8984 }),
8985 )
8986 .await;
8987 assert_eq!(chat.status(), AxumStatusCode::OK);
8988 let chat_body = response_json(chat).await;
8989 assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
8990
8991 let completion = post_json(
8992 router_with_stub("done<END>"),
8993 "/v1/completions",
8994 json!({
8995 "model": "stub-model",
8996 "prompt": "complete",
8997 "stop": "<END>"
8998 }),
8999 )
9000 .await;
9001 assert_eq!(completion.status(), AxumStatusCode::OK);
9002 let completion_body = response_json(completion).await;
9003 assert_eq!(completion_body["choices"][0]["text"], "done");
9004 }
9005
9006 #[test]
9007 fn started_in_think_parse_streams_reasoning_before_end_tag() {
9008 let parsed = parse_reasoning_response_started_in_think("Okay, the user wants");
9012 assert_eq!(parsed.reasoning.as_deref(), Some("Okay, the user wants"));
9013 assert_eq!(parsed.content, "");
9014
9015 let parsed = parse_reasoning_response_started_in_think("thinking...</think>\nanswer");
9016 assert_eq!(parsed.reasoning.as_deref(), Some("thinking..."));
9017 assert_eq!(parsed.content, "answer");
9018
9019 let parsed = parse_reasoning_response_started_in_think("<think>\nx\n</think>\n\nanswer");
9021 assert_eq!(parsed.reasoning.as_deref(), Some("\nx\n"));
9022 assert_eq!(parsed.content, "answer");
9023 }
9024
9025 #[tokio::test]
9026 async fn chat_response_splits_reasoning_from_content() {
9027 let response = post_json(
9028 router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
9029 "/v1/chat/completions",
9030 json!({
9031 "model": "stub-model",
9032 "messages": [{"role": "user", "content": "hello"}]
9033 }),
9034 )
9035 .await;
9036 assert_eq!(response.status(), AxumStatusCode::OK);
9037
9038 let body = response_json(response).await;
9039 let message = &body["choices"][0]["message"];
9040 assert_eq!(message["content"], "final answer");
9041 assert_eq!(message["reasoning"], "\nreasoning\n");
9042 assert!(message.get("reasoning_content").is_none());
9043 }
9044
9045 #[tokio::test]
9046 async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
9047 let response = post_json(
9048 router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
9049 "/v1/chat/completions",
9050 json!({
9051 "model": "stub-model",
9052 "messages": [{"role": "user", "content": "think then answer"}],
9053 "stream": true
9054 }),
9055 )
9056 .await;
9057 assert_eq!(response.status(), AxumStatusCode::OK);
9058 let body = response_text(response).await;
9059 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9060 assert!(
9061 body.contains(r#""reasoning":"\nreason"#),
9062 "stream should emit reasoning delta after full think prefix: {body}"
9063 );
9064 assert!(
9065 body.contains(r#""content":"final""#),
9066 "stream should emit visible content after think close: {body}"
9067 );
9068 assert!(
9069 !body.contains(r#""content":"<"#),
9070 "partial think prefix must not leak as content: {body}"
9071 );
9072 }
9073
9074 #[tokio::test]
9075 async fn route_rejects_unsupported_tool_and_function_selection() {
9076 for (extra, param) in [
9077 (
9078 json!({
9079 "tools": [{
9080 "type": "function",
9081 "function": {"name": "weather", "parameters": {"type": "object"}}
9082 }],
9083 "tool_choice": {
9084 "type": "function",
9085 "function": {"name": "calendar"}
9086 }
9087 }),
9088 "tool_choice",
9089 ),
9090 (
9091 json!({
9092 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
9093 "function_call": {"name": "calendar"}
9094 }),
9095 "function_call",
9096 ),
9097 ] {
9098 let mut body = json!({
9099 "model": "stub-model",
9100 "messages": [{"role": "user", "content": "hello"}]
9101 });
9102 body.as_object_mut()
9103 .expect("object")
9104 .extend(extra.as_object().expect("extra object").clone());
9105 let response =
9106 post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
9107 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9108 let body = response_json(response).await;
9109 assert_eq!(body["error"]["type"], "invalid_request_error");
9110 assert_eq!(body["error"]["param"], param);
9111 }
9112 }
9113
9114 #[tokio::test]
9115 async fn route_rejects_non_function_tools_with_openai_error_param() {
9116 let response = post_json(
9117 router_with_stub("unused"),
9118 "/v1/chat/completions",
9119 json!({
9120 "model": "stub-model",
9121 "messages": [{"role": "user", "content": "hello"}],
9122 "tools": [{
9123 "type": "retrieval",
9124 "function": {"name": "search", "parameters": {"type": "object"}}
9125 }]
9126 }),
9127 )
9128 .await;
9129 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9130 let body = response_json(response).await;
9131 assert_eq!(body["error"]["type"], "invalid_request_error");
9132 assert_eq!(body["error"]["param"], "tools");
9133 }
9134
9135 #[tokio::test]
9136 async fn route_rejects_tool_choice_required_without_tools() {
9137 let response = post_json(
9138 router_with_stub("unused"),
9139 "/v1/chat/completions",
9140 json!({
9141 "model": "stub-model",
9142 "messages": [{"role": "user", "content": "hello"}],
9143 "tool_choice": "required"
9144 }),
9145 )
9146 .await;
9147 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9148 let body = response_json(response).await;
9149 assert_eq!(body["error"]["type"], "invalid_request_error");
9150 assert_eq!(body["error"]["param"], "tool_choice");
9151 }
9152
9153 #[tokio::test]
9154 async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
9155 let response = post_json(
9156 router_with_stub("unused"),
9157 "/v1/chat/completions",
9158 json!({
9159 "model": "stub-model",
9160 "messages": [{"role": "user", "content": "hello"}],
9161 "response_format": {"type": "xml"}
9162 }),
9163 )
9164 .await;
9165 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9166 let body = response_json(response).await;
9167 assert_eq!(body["error"]["type"], "invalid_request_error");
9168 assert_eq!(body["error"]["param"], "response_format.type");
9169 }
9170
9171 #[tokio::test]
9172 async fn route_chat_engine_unavailable_maps_to_503() {
9173 let response = post_json(
9174 router_without_llm(),
9175 "/v1/chat/completions",
9176 json!({
9177 "model": "stub-model",
9178 "messages": [{"role": "user", "content": "hello"}]
9179 }),
9180 )
9181 .await;
9182 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9183 let body = response_json(response).await;
9184 assert_eq!(body["error"]["type"], "service_unavailable_error");
9185 assert_eq!(body["error"]["param"], Value::Null);
9186 }
9187
9188 #[tokio::test]
9189 async fn route_chat_generation_failure_maps_to_500() {
9190 let response = post_json(
9191 router_with_failing_llm(),
9192 "/v1/chat/completions",
9193 json!({
9194 "model": "failing-model",
9195 "messages": [{"role": "user", "content": "hello"}]
9196 }),
9197 )
9198 .await;
9199 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9200 let body = response_json(response).await;
9201 assert_eq!(body["error"]["type"], "internal_server_error");
9202 assert!(body["error"]["message"]
9203 .as_str()
9204 .unwrap()
9205 .contains("stub generation failed"));
9206 }
9207
9208 #[tokio::test]
9209 async fn route_chat_generation_failure_writes_replay_diagnostics() {
9210 let root = unique_request_dump_dir("chat-sync-failure");
9211 let profile = unique_profile_jsonl("chat-sync-failure");
9212 let response = post_json(
9213 router_with_failing_llm_request_dump_and_profile(root.clone(), profile.clone()),
9214 "/v1/chat/completions",
9215 json!({
9216 "model": "failing-model",
9217 "messages": [{"role": "user", "content": "hello"}]
9218 }),
9219 )
9220 .await;
9221 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9222 assert_chat_failure_replay_bundle(
9223 &root,
9224 "chat_completions_sync",
9225 "internal",
9226 "stub generation failed",
9227 );
9228 let event = read_profile_events(&profile)
9229 .into_iter()
9230 .find(|event| event["phase"] == "chat_completions_sync")
9231 .expect("sync failure profile event");
9232 assert_eq!(event["event_kind"], "timed_span");
9233 assert_eq!(event["status"], "failure");
9234 assert!(event["duration_us"].as_u64().is_some());
9235 assert_eq!(event["attributes"]["terminal_failure_event"], true);
9236 assert_eq!(event["error"]["kind"], "internal");
9237 let _ = fs::remove_dir_all(root);
9238 let _ = fs::remove_file(profile);
9239 }
9240
9241 #[tokio::test]
9242 async fn route_chat_resource_failure_writes_resource_replay_diagnostics() {
9243 let root = unique_request_dump_dir("chat-resource-failure");
9244 let response = post_json(
9245 router_with_resource_exhausted_llm_and_request_dump_dir(root.clone()),
9246 "/v1/chat/completions",
9247 json!({
9248 "model": "failing-model",
9249 "messages": [{"role": "user", "content": "hello"}]
9250 }),
9251 )
9252 .await;
9253 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9254 let bundle = only_replay_bundle(&root);
9255 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
9256 assert_eq!(bad_scan["failure_kind"], "oom_admission");
9257 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
9258 assert_eq!(diagnostics["failure_kind"], "oom_admission");
9259 assert_eq!(
9260 diagnostics["first_failure_event"]["error_kind"],
9261 "resource_exhausted"
9262 );
9263 assert_eq!(
9264 diagnostics["capacity"]["resource_kind"],
9265 "admission_capacity"
9266 );
9267 assert!(diagnostics["capacity"]["reason"]
9268 .as_str()
9269 .expect("capacity reason")
9270 .contains("admission capacity exhausted"));
9271 assert_eq!(
9272 diagnostics["nearest_resource_event"]["resource_kind"],
9273 "admission_capacity"
9274 );
9275 assert!(diagnostics["nearest_memory_snapshot"]["current_bytes"].is_number());
9276 assert!(diagnostics["nearest_memory_snapshot"]["high_water_bytes"].is_number());
9277 let _ = fs::remove_dir_all(root);
9278 }
9279
9280 #[tokio::test]
9281 async fn route_chat_sync_success_updates_replay_output_tokens() {
9282 let root = unique_request_dump_dir("chat-sync-success-output");
9283 let response = post_json(
9284 router_with_stub_and_request_dump_dir("OK", root.clone()),
9285 "/v1/chat/completions",
9286 json!({
9287 "model": "stub-model",
9288 "messages": [{"role": "user", "content": "hello"}]
9289 }),
9290 )
9291 .await;
9292 assert_eq!(response.status(), AxumStatusCode::OK);
9293 let body = response_json(response).await;
9294 assert_eq!(body["choices"][0]["message"]["content"], "OK");
9295 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
9296 let _ = fs::remove_dir_all(root);
9297 }
9298
9299 #[tokio::test]
9300 async fn route_chat_sync_success_writes_product_profile_event() {
9301 let root = unique_request_dump_dir("chat-sync-profile");
9302 let profile = unique_profile_jsonl("chat-sync-profile");
9303 let response = post_json(
9304 router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
9305 "/v1/chat/completions",
9306 json!({
9307 "model": "stub-model",
9308 "messages": [{"role": "user", "content": "hello"}]
9309 }),
9310 )
9311 .await;
9312 assert_eq!(response.status(), AxumStatusCode::OK);
9313 let _ = response_json(response).await;
9314
9315 let events = read_profile_events(&profile);
9316 assert_eq!(events.len(), 2, "events: {events:#?}");
9317 let event = events
9318 .iter()
9319 .find(|event| event["phase"] == "chat_completions_sync_complete")
9320 .expect("sync completion profile event");
9321 assert_eq!(
9322 event["schema_version"],
9323 OBSERVABILITY_PROFILE_SCHEMA_VERSION
9324 );
9325 assert_eq!(event["entrypoint"], "serve");
9326 assert_eq!(event["event_kind"], "timed_span");
9327 assert_eq!(event["status"], "ok");
9328 assert_eq!(event["phase"], "chat_completions_sync_complete");
9329 assert_eq!(event["attributes"]["actual_model_smoke"], true);
9330 assert_eq!(event["attributes"]["profile_detail"], "latency");
9331 assert_eq!(event["attributes"]["diagnostic_only"], false);
9332 assert_eq!(event["attributes"]["stream"], false);
9333 assert_eq!(event["attributes"]["output_token_count"], 2);
9334 assert_eq!(event["attributes"]["prompt_token_count"], 7);
9335 assert_eq!(event["attributes"]["completion_token_count"], 2);
9336 assert_eq!(event["attributes"]["total_token_count"], 9);
9337 assert_eq!(event["attributes"]["token_count_source"], "usage");
9338 assert_eq!(event["attributes"]["finish_reason"], "stop");
9339 assert_eq!(
9340 event["attributes"]["engine_token_clock_source"],
9341 "rust_std_instant"
9342 );
9343 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
9344 assert_eq!(event["attributes"]["itl_interval_count"], 1);
9345 assert_eq!(event["attributes"]["ttft_us"], 1_000);
9346 assert_eq!(event["attributes"]["itl_us_avg"], 1_000);
9347 assert!(event["attributes"]["http_first_sse_enqueue_us"].is_null());
9348 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
9349 assert!(
9350 event["attributes"]["e2e_duration_us"]
9351 .as_u64()
9352 .unwrap_or_default()
9353 > 0
9354 );
9355 assert_eq!(
9356 event["replay"]["bundle_dir"].as_str(),
9357 Some(root.to_string_lossy().as_ref())
9358 );
9359 assert!(event["replay"]["command"]
9360 .as_str()
9361 .unwrap_or_default()
9362 .contains("replay_body.json"));
9363 let memory_event = events
9364 .iter()
9365 .find(|event| event["phase"] == "actual_serve_first_request_done")
9366 .expect("first request memory profile event");
9367 assert_eq!(memory_event["event_kind"], "memory");
9368 assert_eq!(
9369 memory_event["attributes"]["memory_stage"],
9370 "first_request_done"
9371 );
9372 assert_eq!(
9373 memory_event["attributes"]["memory_measurement"],
9374 "process_rss"
9375 );
9376 assert!(memory_event["memory"]["current_bytes"]
9377 .as_u64()
9378 .is_some_and(|bytes| bytes > 0));
9379 let _ = fs::remove_dir_all(root);
9380 let _ = fs::remove_file(profile);
9381 }
9382
9383 #[tokio::test]
9384 async fn route_chat_sync_profile_jsonl_is_parseable_under_concurrent_requests() {
9385 let root = unique_request_dump_dir("chat-sync-profile-concurrent");
9386 let profile = unique_profile_jsonl("chat-sync-profile-concurrent");
9387 let app = router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone());
9388
9389 let mut handles = Vec::new();
9390 for request_index in 0..8 {
9391 let app = app.clone();
9392 handles.push(tokio::spawn(async move {
9393 let response = post_json(
9394 app,
9395 "/v1/chat/completions",
9396 json!({
9397 "model": "stub-model",
9398 "messages": [{"role": "user", "content": format!("hello {request_index}")}]
9399 }),
9400 )
9401 .await;
9402 assert_eq!(response.status(), AxumStatusCode::OK);
9403 let body = response_json(response).await;
9404 assert_eq!(body["choices"][0]["message"]["content"], "OK");
9405 }));
9406 }
9407
9408 for handle in handles {
9409 handle.await.expect("concurrent request task");
9410 }
9411
9412 let raw = fs::read_to_string(&profile).expect("profile jsonl");
9413 let mut completion_events = 0usize;
9414 for (line_index, line) in raw
9415 .lines()
9416 .filter(|line| !line.trim().is_empty())
9417 .enumerate()
9418 {
9419 let event: Value = serde_json::from_str(line).unwrap_or_else(|err| {
9420 panic!(
9421 "profile line {} invalid JSON: {err}: {line}",
9422 line_index + 1
9423 )
9424 });
9425 if event["phase"] == "chat_completions_sync_complete" {
9426 completion_events += 1;
9427 }
9428 }
9429 assert_eq!(completion_events, 8);
9430 let _ = fs::remove_dir_all(root);
9431 let _ = fs::remove_file(profile);
9432 }
9433
9434 #[tokio::test]
9435 async fn route_chat_stream_success_updates_replay_output_tokens() {
9436 let root = unique_request_dump_dir("chat-stream-success-output");
9437 let response = post_json(
9438 router_with_stub_stream_chunks_and_request_dump_dir(&["O", "K"], root.clone()),
9439 "/v1/chat/completions",
9440 json!({
9441 "model": "stub-model",
9442 "messages": [{"role": "user", "content": "hello"}],
9443 "stream": true
9444 }),
9445 )
9446 .await;
9447 assert_eq!(response.status(), AxumStatusCode::OK);
9448 let body = response_text(response).await;
9449 assert!(body.contains("data: [DONE]"), "body: {body}");
9450 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
9451 let _ = fs::remove_dir_all(root);
9452 }
9453
9454 #[tokio::test]
9455 async fn route_chat_stream_success_writes_product_profile_event() {
9456 let root = unique_request_dump_dir("chat-stream-profile");
9457 let profile = unique_profile_jsonl("chat-stream-profile");
9458 let response = post_json(
9459 router_with_stub_stream_request_dump_and_profile(
9460 &["O", "K"],
9461 root.clone(),
9462 profile.clone(),
9463 ),
9464 "/v1/chat/completions",
9465 json!({
9466 "model": "stub-model",
9467 "messages": [{"role": "user", "content": "hello"}],
9468 "stream": true
9469 }),
9470 )
9471 .await;
9472 assert_eq!(response.status(), AxumStatusCode::OK);
9473 let body = response_text(response).await;
9474 assert!(body.contains("data: [DONE]"), "body: {body}");
9475
9476 let events = read_profile_events(&profile);
9477 assert_eq!(events.len(), 2, "events: {events:#?}");
9478 let event = events
9479 .iter()
9480 .find(|event| event["phase"] == "chat_completions_stream_complete")
9481 .expect("stream completion profile event");
9482 assert_eq!(
9483 event["schema_version"],
9484 OBSERVABILITY_PROFILE_SCHEMA_VERSION
9485 );
9486 assert_eq!(event["entrypoint"], "serve");
9487 assert_eq!(event["event_kind"], "timed_span");
9488 assert_eq!(event["status"], "ok");
9489 assert_eq!(event["phase"], "chat_completions_stream_complete");
9490 assert_eq!(event["attributes"]["actual_model_smoke"], true);
9491 assert_eq!(event["attributes"]["profile_detail"], "latency");
9492 assert_eq!(event["attributes"]["diagnostic_only"], false);
9493 assert_eq!(event["attributes"]["stream"], true);
9494 assert_eq!(event["attributes"]["output_token_count"], 2);
9495 assert_eq!(event["attributes"]["prompt_token_count"], 5);
9496 assert_eq!(event["attributes"]["completion_token_count"], 2);
9497 assert_eq!(event["attributes"]["total_token_count"], 7);
9498 assert_eq!(event["attributes"]["token_count_source"], "usage");
9499 assert_eq!(event["attributes"]["finish_reason"], "stop");
9500 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
9501 assert!(
9502 event["attributes"]["e2e_duration_us"]
9503 .as_u64()
9504 .unwrap_or_default()
9505 > 0
9506 );
9507 assert!(event["attributes"]["ttft_us"].as_u64().is_some());
9508 assert!(event["attributes"]["itl_us_avg"].as_u64().is_some());
9509 assert_eq!(
9510 event["attributes"]["engine_token_commit_nanos_since_request_start"],
9511 json!([1_000_000, 2_000_000])
9512 );
9513 assert_eq!(event["attributes"]["itl_interval_count"], 1);
9514 assert_eq!(event["attributes"]["itl_source"], "engine_token_commit");
9515 assert!(event["attributes"]["engine_stream_first_chunk_received_us"]
9516 .as_u64()
9517 .is_some());
9518 assert!(event["attributes"]["http_first_sse_enqueue_us"]
9519 .as_u64()
9520 .is_some());
9521 assert!(event["attributes"]["http_stream_flush_unavailable_reason"]
9522 .as_str()
9523 .is_some());
9524 assert_eq!(
9525 event["replay"]["bundle_dir"].as_str(),
9526 Some(root.to_string_lossy().as_ref())
9527 );
9528 let memory_event = events
9529 .iter()
9530 .find(|event| event["phase"] == "actual_serve_first_request_done")
9531 .expect("first request memory profile event");
9532 assert_eq!(memory_event["event_kind"], "memory");
9533 assert_eq!(
9534 memory_event["attributes"]["memory_stage"],
9535 "first_request_done"
9536 );
9537 assert_eq!(
9538 memory_event["attributes"]["memory_measurement"],
9539 "process_rss"
9540 );
9541 assert!(memory_event["memory"]["current_bytes"]
9542 .as_u64()
9543 .is_some_and(|bytes| bytes > 0));
9544 let _ = fs::remove_dir_all(root);
9545 let _ = fs::remove_file(profile);
9546 }
9547
9548 #[tokio::test]
9549 async fn route_chat_stream_profile_retains_non_visible_terminal_token() {
9550 let root = unique_request_dump_dir("chat-stream-profile-terminal-token");
9551 let profile = unique_profile_jsonl("chat-stream-profile-terminal-token");
9552 let llm = StubLlm {
9553 stream_usage: Some(TokenUsage::new(5, 2)),
9554 ..StubLlm::with_stream_chunks(&["Paris"])
9555 };
9556 let app = AxumServer::from_state(
9557 AppState::default()
9558 .with_llm(Arc::new(llm))
9559 .with_request_dump_dir(Some(root.clone()))
9560 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
9561 .with_profile_jsonl(Some(profile.clone())),
9562 )
9563 .build_router();
9564
9565 let response = post_json(
9566 app,
9567 "/v1/chat/completions",
9568 json!({
9569 "model": "stub-model",
9570 "messages": [{"role": "user", "content": "hello"}],
9571 "stream": true,
9572 "stream_options": {"include_usage": true}
9573 }),
9574 )
9575 .await;
9576 assert_eq!(response.status(), AxumStatusCode::OK);
9577 let body = response_text(response).await;
9578 assert!(body.contains("\"completion_tokens\":2"), "body: {body}");
9579 assert!(body.contains("data: [DONE]"), "body: {body}");
9580
9581 let events = read_profile_events(&profile);
9582 let event = events
9583 .iter()
9584 .find(|event| event["phase"] == "chat_completions_stream_complete")
9585 .expect("stream completion profile event");
9586 assert_eq!(event["attributes"]["output_token_count"], 2);
9587 assert_eq!(event["attributes"]["completion_token_count"], 2);
9588 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
9589 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "Paris");
9590
9591 let _ = fs::remove_dir_all(root);
9592 let _ = fs::remove_file(profile);
9593 }
9594
9595 #[tokio::test]
9596 async fn route_chat_sync_bad_output_updates_replay_classifier() {
9597 let root = unique_request_dump_dir("chat-sync-bad-output");
9598 let response = post_json(
9599 router_with_stub_and_request_dump_dir("<unk>", root.clone()),
9600 "/v1/chat/completions",
9601 json!({
9602 "model": "stub-model",
9603 "messages": [{"role": "user", "content": "hello"}]
9604 }),
9605 )
9606 .await;
9607 assert_eq!(response.status(), AxumStatusCode::OK);
9608 let _ = response_json(response).await;
9609 let bundle = only_replay_bundle(&root);
9610 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
9611 assert_eq!(bad_scan["bad_output"], true);
9612 assert_eq!(bad_scan["reasons"], json!(["reserved_token"]));
9613 assert_eq!(bad_scan["first_bad_text_span"]["reason"], "reserved_token");
9614 let _ = fs::remove_dir_all(root);
9615 }
9616
9617 #[tokio::test]
9618 async fn route_chat_stream_generation_failure_emits_openai_error_event() {
9619 let response = post_json(
9620 router_with_failing_llm(),
9621 "/v1/chat/completions",
9622 json!({
9623 "model": "failing-model",
9624 "messages": [{"role": "user", "content": "hello"}],
9625 "stream": true
9626 }),
9627 )
9628 .await;
9629 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9630 let body = response_json(response).await;
9631 assert_eq!(body["error"]["type"], "internal_server_error");
9632 assert!(body["error"]["message"]
9633 .as_str()
9634 .unwrap_or_default()
9635 .contains("stub stream failed"));
9636 }
9637
9638 #[tokio::test]
9639 async fn route_chat_stream_generation_failure_writes_replay_diagnostics() {
9640 let root = unique_request_dump_dir("chat-stream-start-failure");
9641 let response = post_json(
9642 router_with_failing_llm_and_request_dump_dir(root.clone()),
9643 "/v1/chat/completions",
9644 json!({
9645 "model": "failing-model",
9646 "messages": [{"role": "user", "content": "hello"}],
9647 "stream": true
9648 }),
9649 )
9650 .await;
9651 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9652 let body = response_json(response).await;
9653 assert_eq!(body["error"]["type"], "internal_server_error");
9654 assert!(body["error"]["message"]
9655 .as_str()
9656 .unwrap_or_default()
9657 .contains("stub stream failed"));
9658 assert_chat_failure_replay_bundle(
9659 &root,
9660 "chat_completions_stream_start",
9661 "internal",
9662 "stub stream failed",
9663 );
9664 let _ = fs::remove_dir_all(root);
9665 }
9666
9667 #[tokio::test]
9668 async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
9669 let response = post_json(
9670 router_with_stream_chunk_failing_llm(),
9671 "/v1/chat/completions",
9672 json!({
9673 "model": "failing-model",
9674 "messages": [{"role": "user", "content": "hello"}],
9675 "stream": true
9676 }),
9677 )
9678 .await;
9679 assert_eq!(response.status(), AxumStatusCode::OK);
9680 let body = response_text(response).await;
9681 assert_openai_stream_error(&body, "stub stream chunk failed");
9682 }
9683
9684 #[tokio::test]
9685 async fn route_chat_stream_chunk_failure_writes_replay_diagnostics() {
9686 let root = unique_request_dump_dir("chat-stream-chunk-failure");
9687 let response = post_json(
9688 router_with_stream_chunk_failing_llm_and_request_dump_dir(root.clone()),
9689 "/v1/chat/completions",
9690 json!({
9691 "model": "failing-model",
9692 "messages": [{"role": "user", "content": "hello"}],
9693 "stream": true
9694 }),
9695 )
9696 .await;
9697 assert_eq!(response.status(), AxumStatusCode::OK);
9698 let body = response_text(response).await;
9699 assert_openai_stream_error(&body, "stub stream chunk failed");
9700 assert_chat_failure_replay_bundle(
9701 &root,
9702 "chat_completions_stream_next",
9703 "internal",
9704 "stub stream chunk failed",
9705 );
9706 let _ = fs::remove_dir_all(root);
9707 }
9708
9709 #[tokio::test]
9710 async fn route_completions_engine_unavailable_maps_to_503() {
9711 let response = post_json(
9712 router_without_llm(),
9713 "/v1/completions",
9714 json!({
9715 "model": "stub-model",
9716 "prompt": "complete me"
9717 }),
9718 )
9719 .await;
9720 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9721 let body = response_json(response).await;
9722 assert_eq!(body["error"]["type"], "service_unavailable_error");
9723 assert_eq!(body["error"]["param"], Value::Null);
9724 }
9725
9726 #[tokio::test]
9727 async fn route_embeddings_engine_unavailable_maps_to_503() {
9728 let response = post_json(
9729 router_without_llm(),
9730 "/v1/embeddings",
9731 json!({
9732 "model": "embed-model",
9733 "input": "hello"
9734 }),
9735 )
9736 .await;
9737 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9738 let body = response_json(response).await;
9739 assert_eq!(body["error"]["type"], "service_unavailable_error");
9740 assert_eq!(body["error"]["param"], Value::Null);
9741 }
9742
9743 #[tokio::test]
9744 async fn route_embeddings_contract_uses_stub_engine() {
9745 let response = post_json(
9746 router_with_stub_embed(),
9747 "/v1/embeddings",
9748 json!({
9749 "model": "stub-embed",
9750 "input": ["hi", "world"],
9751 "encoding_format": "float"
9752 }),
9753 )
9754 .await;
9755 assert_eq!(response.status(), AxumStatusCode::OK);
9756 let body = response_json(response).await;
9757 assert_eq!(body["object"], "list");
9758 assert_eq!(body["model"], "stub-embed");
9759 assert_eq!(body["usage"]["prompt_tokens"], 7);
9760 assert_eq!(body["usage"]["total_tokens"], 7);
9761
9762 let data = body["data"].as_array().expect("embedding data");
9763 assert_eq!(data.len(), 2, "body: {body}");
9764 assert_eq!(data[0]["object"], "embedding");
9765 assert_eq!(data[0]["index"], 0);
9766 assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
9767 assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
9768 assert_eq!(data[1]["index"], 1);
9769 assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
9770 }
9771
9772 #[tokio::test]
9773 async fn route_embeddings_public_alias_succeeds_and_unknown_alias_is_rejected() {
9774 let registry = ServedModelRegistry::try_new(
9775 "stub-embed",
9776 ServedModelKind::Embedding,
9777 vec!["public-embed".to_string()],
9778 vec![],
9779 )
9780 .unwrap();
9781 let server =
9782 AxumServer::from_embed(Arc::new(StubEmbed::new())).with_served_model_registry(registry);
9783 let accepted = post_json(
9784 server.build_router(),
9785 "/v1/embeddings",
9786 json!({"model": "public-embed", "input": "hello"}),
9787 )
9788 .await;
9789 assert_eq!(accepted.status(), AxumStatusCode::OK);
9790 assert_eq!(response_json(accepted).await["model"], "public-embed");
9791
9792 let rejected = post_json(
9793 server.build_router(),
9794 "/v1/embeddings",
9795 json!({"model": "stub-embed", "input": "hello"}),
9796 )
9797 .await;
9798 assert_eq!(rejected.status(), AxumStatusCode::BAD_REQUEST);
9799 let body = response_json(rejected).await;
9800 assert_eq!(body["error"]["type"], "invalid_request_error");
9801 assert_eq!(body["error"]["param"], "model");
9802 }
9803
9804 #[tokio::test]
9805 async fn route_embeddings_rejects_unsupported_encoding_format() {
9806 let response = post_json(
9807 router_with_stub_embed(),
9808 "/v1/embeddings",
9809 json!({
9810 "model": "stub-embed",
9811 "input": "hi",
9812 "encoding_format": "base64"
9813 }),
9814 )
9815 .await;
9816 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9817 let body = response_json(response).await;
9818 assert_eq!(body["error"]["type"], "invalid_request_error");
9819 assert_eq!(body["error"]["param"], "encoding_format");
9820 }
9821
9822 #[tokio::test]
9823 async fn route_embeddings_rejects_empty_input_with_field_param() {
9824 let response = post_json(
9825 router_with_stub_embed(),
9826 "/v1/embeddings",
9827 json!({
9828 "model": "stub-embed",
9829 "input": []
9830 }),
9831 )
9832 .await;
9833 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9834 let body = response_json(response).await;
9835 assert_eq!(body["error"]["type"], "invalid_request_error");
9836 assert_eq!(body["error"]["param"], "input");
9837 }
9838
9839 #[tokio::test]
9840 async fn route_embeddings_rejects_empty_item_with_field_param() {
9841 let response = post_json(
9842 router_with_stub_embed(),
9843 "/v1/embeddings",
9844 json!({
9845 "model": "stub-embed",
9846 "input": [{}]
9847 }),
9848 )
9849 .await;
9850 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9851 let body = response_json(response).await;
9852 assert_eq!(body["error"]["type"], "invalid_request_error");
9853 assert_eq!(body["error"]["param"], "input");
9854 }
9855
9856 #[tokio::test]
9857 async fn route_embeddings_invalid_json_maps_to_openai_error() {
9858 let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
9859 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9860 let body = response_json(response).await;
9861 assert_eq!(body["error"]["type"], "invalid_request_error");
9862 assert_eq!(body["error"]["param"], Value::Null);
9863 assert!(body["error"]["message"]
9864 .as_str()
9865 .unwrap()
9866 .contains("invalid embeddings request"));
9867 }
9868
9869 #[tokio::test]
9870 async fn route_transcriptions_engine_unavailable_maps_to_503() {
9871 let boundary = "ferrum-test-boundary";
9872 let body = concat!(
9873 "--ferrum-test-boundary\r\n",
9874 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
9875 "Content-Type: audio/wav\r\n",
9876 "\r\n",
9877 "RIFFtest\r\n",
9878 "--ferrum-test-boundary--\r\n"
9879 );
9880 let response = post_multipart(
9881 router_without_llm(),
9882 "/v1/audio/transcriptions",
9883 boundary,
9884 body,
9885 )
9886 .await;
9887 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
9888 let body = response_json(response).await;
9889 assert_eq!(body["error"]["type"], "service_unavailable_error");
9890 assert_eq!(body["error"]["param"], Value::Null);
9891 }
9892
9893 #[tokio::test]
9894 async fn route_transcriptions_contract_uses_stub_engine() {
9895 let boundary = "ferrum-test-boundary";
9896 let body = concat!(
9897 "--ferrum-test-boundary\r\n",
9898 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
9899 "Content-Type: audio/wav\r\n",
9900 "\r\n",
9901 "RIFFtest\r\n",
9902 "--ferrum-test-boundary\r\n",
9903 "Content-Disposition: form-data; name=\"language\"\r\n",
9904 "\r\n",
9905 "en\r\n",
9906 "--ferrum-test-boundary\r\n",
9907 "Content-Disposition: form-data; name=\"response_format\"\r\n",
9908 "\r\n",
9909 "json\r\n",
9910 "--ferrum-test-boundary--\r\n"
9911 );
9912 let response = post_multipart(
9913 router_with_stub_transcribe(),
9914 "/v1/audio/transcriptions",
9915 boundary,
9916 body,
9917 )
9918 .await;
9919 assert_eq!(response.status(), AxumStatusCode::OK);
9920 let body = response_json(response).await;
9921 assert_eq!(body["text"], "bytes:8:en");
9922 }
9923
9924 #[tokio::test]
9925 async fn route_transcriptions_rejects_unsupported_response_format() {
9926 let boundary = "ferrum-test-boundary";
9927 let body = concat!(
9928 "--ferrum-test-boundary\r\n",
9929 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
9930 "Content-Type: audio/wav\r\n",
9931 "\r\n",
9932 "RIFFtest\r\n",
9933 "--ferrum-test-boundary\r\n",
9934 "Content-Disposition: form-data; name=\"response_format\"\r\n",
9935 "\r\n",
9936 "text\r\n",
9937 "--ferrum-test-boundary--\r\n"
9938 );
9939 let response = post_multipart(
9940 router_with_stub_transcribe(),
9941 "/v1/audio/transcriptions",
9942 boundary,
9943 body,
9944 )
9945 .await;
9946 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9947 let body = response_json(response).await;
9948 assert_eq!(body["error"]["type"], "invalid_request_error");
9949 assert_eq!(body["error"]["param"], "response_format");
9950 }
9951
9952 #[tokio::test]
9953 async fn route_transcriptions_rejects_missing_file_with_field_param() {
9954 let boundary = "ferrum-test-boundary";
9955 let body = concat!(
9956 "--ferrum-test-boundary\r\n",
9957 "Content-Disposition: form-data; name=\"language\"\r\n",
9958 "\r\n",
9959 "en\r\n",
9960 "--ferrum-test-boundary--\r\n"
9961 );
9962 let response = post_multipart(
9963 router_with_stub_transcribe(),
9964 "/v1/audio/transcriptions",
9965 boundary,
9966 body,
9967 )
9968 .await;
9969 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9970 let body = response_json(response).await;
9971 assert_eq!(body["error"]["type"], "invalid_request_error");
9972 assert_eq!(body["error"]["param"], "file");
9973 }
9974
9975 #[tokio::test]
9976 async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
9977 let response = post_json(
9978 router_with_stub_transcribe(),
9979 "/v1/audio/transcriptions",
9980 json!({}),
9981 )
9982 .await;
9983 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9984 let body = response_json(response).await;
9985 assert_eq!(body["error"]["type"], "invalid_request_error");
9986 assert_eq!(body["error"]["param"], Value::Null);
9987 assert!(body["error"]["message"]
9988 .as_str()
9989 .unwrap()
9990 .contains("invalid transcriptions request"));
9991 }
9992
9993 #[tokio::test]
9994 async fn route_speech_engine_unavailable_maps_to_503() {
9995 let response = post_json(
9996 router_without_llm(),
9997 "/v1/audio/speech",
9998 json!({
9999 "model": "tts-model",
10000 "input": "hello",
10001 "voice": "default"
10002 }),
10003 )
10004 .await;
10005 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
10006 let body = response_json(response).await;
10007 assert_eq!(body["error"]["type"], "service_unavailable_error");
10008 assert_eq!(body["error"]["param"], Value::Null);
10009 }
10010
10011 #[tokio::test]
10012 async fn route_speech_contract_uses_stub_engine() {
10013 let response = post_json(
10014 router_with_stub_tts(),
10015 "/v1/audio/speech",
10016 json!({
10017 "model": "stub-tts",
10018 "input": "hello",
10019 "voice": "default",
10020 "response_format": "wav",
10021 "language": "english"
10022 }),
10023 )
10024 .await;
10025 assert_eq!(response.status(), AxumStatusCode::OK);
10026 assert_eq!(
10027 response.headers().get(header::CONTENT_TYPE).unwrap(),
10028 "audio/wav"
10029 );
10030 let body = response_bytes(response).await;
10031 assert!(body.len() > 44, "WAV should include header and PCM data");
10032 assert_eq!(&body[0..4], b"RIFF");
10033 assert_eq!(&body[8..12], b"WAVE");
10034 }
10035
10036 #[tokio::test]
10037 async fn route_speech_streaming_contract_uses_stub_engine() {
10038 let response = post_json(
10039 router_with_stub_tts(),
10040 "/v1/audio/speech",
10041 json!({
10042 "model": "stub-tts",
10043 "input": "hello",
10044 "voice": "default",
10045 "response_format": "wav",
10046 "stream": true
10047 }),
10048 )
10049 .await;
10050 assert_eq!(response.status(), AxumStatusCode::OK);
10051 assert_eq!(
10052 response.headers().get(header::CONTENT_TYPE).unwrap(),
10053 "audio/wav"
10054 );
10055 assert_eq!(
10056 response.headers().get(header::TRANSFER_ENCODING).unwrap(),
10057 "chunked"
10058 );
10059 let body = response_bytes(response).await;
10060 assert!(body.len() > 44, "streaming WAV should include audio bytes");
10061 assert_eq!(&body[0..4], b"RIFF");
10062 assert_eq!(&body[8..12], b"WAVE");
10063 }
10064
10065 #[tokio::test]
10066 async fn route_speech_pcm_response_format_returns_raw_pcm() {
10067 let response = post_json(
10068 router_with_stub_tts(),
10069 "/v1/audio/speech",
10070 json!({
10071 "model": "stub-tts",
10072 "input": "hello",
10073 "voice": "default",
10074 "response_format": "pcm"
10075 }),
10076 )
10077 .await;
10078 assert_eq!(response.status(), AxumStatusCode::OK);
10079 assert_eq!(
10080 response.headers().get(header::CONTENT_TYPE).unwrap(),
10081 "audio/pcm"
10082 );
10083 let body = response_bytes(response).await;
10084 assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
10085 assert_eq!(&body[0..2], &[0, 0]);
10086 assert_ne!(&body[0..4], b"RIFF");
10087 }
10088
10089 #[tokio::test]
10090 async fn route_speech_rejects_unsupported_response_format() {
10091 let response = post_json(
10092 router_with_stub_tts(),
10093 "/v1/audio/speech",
10094 json!({
10095 "model": "stub-tts",
10096 "input": "hello",
10097 "voice": "default",
10098 "response_format": "mp3"
10099 }),
10100 )
10101 .await;
10102 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10103 let body = response_json(response).await;
10104 assert_eq!(body["error"]["type"], "invalid_request_error");
10105 assert_eq!(body["error"]["param"], "response_format");
10106 }
10107
10108 #[tokio::test]
10109 async fn route_speech_invalid_json_maps_to_openai_error() {
10110 let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
10111 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10112 let body = response_json(response).await;
10113 assert_eq!(body["error"]["type"], "invalid_request_error");
10114 assert_eq!(body["error"]["param"], Value::Null);
10115 assert!(body["error"]["message"]
10116 .as_str()
10117 .unwrap()
10118 .contains("invalid speech request"));
10119 }
10120
10121 #[tokio::test]
10122 async fn route_completions_generation_failure_maps_to_500() {
10123 let response = post_json(
10124 router_with_failing_llm(),
10125 "/v1/completions",
10126 json!({
10127 "model": "failing-model",
10128 "prompt": "complete me"
10129 }),
10130 )
10131 .await;
10132 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
10133 let body = response_json(response).await;
10134 assert_eq!(body["error"]["type"], "internal_server_error");
10135 assert!(body["error"]["message"]
10136 .as_str()
10137 .unwrap()
10138 .contains("stub generation failed"));
10139 }
10140
10141 #[tokio::test]
10142 async fn route_completions_stream_generation_failure_emits_openai_error_event() {
10143 let response = post_json(
10144 router_with_failing_llm(),
10145 "/v1/completions",
10146 json!({
10147 "model": "failing-model",
10148 "prompt": "complete me",
10149 "stream": true
10150 }),
10151 )
10152 .await;
10153 assert_eq!(response.status(), AxumStatusCode::OK);
10154 let body = response_text(response).await;
10155 assert_openai_stream_error(&body, "stub stream failed");
10156 }
10157
10158 #[tokio::test]
10159 async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
10160 let response = post_json(
10161 router_with_stream_chunk_failing_llm(),
10162 "/v1/completions",
10163 json!({
10164 "model": "failing-model",
10165 "prompt": "complete me",
10166 "stream": true
10167 }),
10168 )
10169 .await;
10170 assert_eq!(response.status(), AxumStatusCode::OK);
10171 let body = response_text(response).await;
10172 assert_openai_stream_error(&body, "stub stream chunk failed");
10173 }
10174
10175 #[tokio::test]
10176 async fn route_completions_contract_uses_stub_engine() {
10177 let response = post_json(
10178 router_with_stub("done"),
10179 "/v1/completions",
10180 json!({
10181 "model": "stub-model",
10182 "prompt": "complete me",
10183 "max_tokens": 8,
10184 "temperature": 0.0
10185 }),
10186 )
10187 .await;
10188 assert_eq!(response.status(), AxumStatusCode::OK);
10189 let body = response_json(response).await;
10190 assert_eq!(body["object"], "text_completion");
10191 assert_eq!(body["choices"][0]["text"], "done");
10192 assert_eq!(body["usage"]["prompt_tokens"], 7);
10193 assert_eq!(body["usage"]["completion_tokens"], 2);
10194 }
10195
10196 #[tokio::test]
10197 async fn route_completions_public_alias_maps_to_internal_model() {
10198 let engine = Arc::new(CapturingLlm::new());
10199 let registry = ServedModelRegistry::try_new(
10200 "qwen3",
10201 ServedModelKind::Llm,
10202 vec!["served-alias".to_string()],
10203 vec![],
10204 )
10205 .unwrap();
10206 let router = AxumServer::from_llm(engine.clone())
10207 .with_served_model_registry(registry)
10208 .build_router();
10209 let response = post_json(
10210 router,
10211 "/v1/completions",
10212 json!({"model": "served-alias", "prompt": "complete me"}),
10213 )
10214 .await;
10215
10216 assert_eq!(response.status(), AxumStatusCode::OK);
10217 assert_eq!(response_json(response).await["model"], "served-alias");
10218 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
10219 }
10220
10221 #[tokio::test]
10222 async fn route_completions_streaming_contract_uses_stub_engine() {
10223 let response = post_json(
10224 router_with_stub("done"),
10225 "/v1/completions",
10226 json!({
10227 "model": "stub-model",
10228 "prompt": "complete me",
10229 "max_tokens": 8,
10230 "temperature": 0.0,
10231 "stream": true
10232 }),
10233 )
10234 .await;
10235 assert_eq!(response.status(), AxumStatusCode::OK);
10236 let body = response_text(response).await;
10237 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10238 assert!(
10239 body.contains("\"object\":\"text_completion\""),
10240 "missing completion chunk: {body}"
10241 );
10242 assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
10243 assert!(
10244 body.contains("\"choices\":[],\"usage\""),
10245 "missing separate usage chunk: {body}"
10246 );
10247 assert!(
10248 body.contains("\"prompt_tokens\":5"),
10249 "stream usage should come from engine token usage: {body}"
10250 );
10251 assert!(
10252 body.contains("\"completion_tokens\":1"),
10253 "stream completion usage should come from engine token usage: {body}"
10254 );
10255 }
10256
10257 #[tokio::test]
10258 async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
10259 let response = post_json(
10260 router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
10261 "/v1/completions",
10262 json!({
10263 "model": "stub-model",
10264 "prompt": "complete me",
10265 "max_tokens": 2,
10266 "temperature": 0.0,
10267 "stream": true
10268 }),
10269 )
10270 .await;
10271 assert_eq!(response.status(), AxumStatusCode::OK);
10272 let body = response_text(response).await;
10273 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10274 assert!(
10275 body.contains("\"text\":\"do\""),
10276 "missing first chunk: {body}"
10277 );
10278 assert!(
10279 body.contains("\"text\":\"ne\""),
10280 "missing second chunk: {body}"
10281 );
10282 assert!(
10283 body.contains("\"choices\":[],\"usage\""),
10284 "missing separate usage chunk from final engine chunk: {body}"
10285 );
10286 assert!(
10287 body.contains("\"prompt_tokens\":5"),
10288 "stream usage should come from engine final usage: {body}"
10289 );
10290 }
10291
10292 #[tokio::test]
10293 async fn route_completions_invalid_json_maps_to_openai_error() {
10294 let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
10295 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10296 let body = response_json(response).await;
10297 assert_eq!(body["error"]["type"], "invalid_request_error");
10298 assert_eq!(body["error"]["param"], Value::Null);
10299 assert!(body["error"]["message"]
10300 .as_str()
10301 .unwrap()
10302 .contains("invalid completions request"));
10303 }
10304
10305 #[tokio::test]
10306 async fn route_completions_rejects_unsupported_fields_explicitly() {
10307 for (extra, param) in [
10308 (json!({"n": 2}), "n"),
10309 (json!({"logprobs": 3}), "logprobs"),
10310 (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
10311 ] {
10312 let mut body = json!({
10313 "model": "stub-model",
10314 "prompt": "complete me"
10315 });
10316 body.as_object_mut()
10317 .expect("object")
10318 .extend(extra.as_object().expect("extra object").clone());
10319 let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
10320 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10321 let body = response_json(response).await;
10322 assert_eq!(body["error"]["type"], "invalid_request_error");
10323 assert_eq!(body["error"]["param"], param);
10324 }
10325 }
10326
10327 #[tokio::test]
10328 async fn streaming_completions_do_not_synthesize_whitespace_usage() {
10329 let response = post_json(
10330 router_with_stub_without_stream_usage("done"),
10331 "/v1/completions",
10332 json!({
10333 "model": "stub-model",
10334 "prompt": "one two three four",
10335 "stream": true
10336 }),
10337 )
10338 .await;
10339 assert_eq!(response.status(), AxumStatusCode::OK);
10340 let body = response_text(response).await;
10341 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10342 assert!(
10343 !body.contains("\"usage\":{\"prompt_tokens\""),
10344 "server must not synthesize whitespace-count completion usage: {body}"
10345 );
10346 }
10347
10348 #[tokio::test]
10349 async fn chat_rejects_n_not_one_with_openai_error_param() {
10350 let request = chat_request(json!({"n": 2}));
10351 let err = chat_completions_handler(
10352 State(state_with_stub("unused")),
10353 HeaderMap::new(),
10354 Ok(Json(request)),
10355 )
10356 .await
10357 .expect_err("n=2 should reject");
10358 let (status, body) = error_json(err).await;
10359 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
10360 assert_eq!(body["error"]["type"], "invalid_request_error");
10361 assert_eq!(body["error"]["param"], "n");
10362 }
10363
10364 #[tokio::test]
10365 async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
10366 for (extra, param) in [
10367 (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
10368 (json!({"logprobs": true}), "logprobs"),
10369 (json!({"top_logprobs": 2}), "top_logprobs"),
10370 ] {
10371 let request = chat_request(extra);
10372 let err = chat_completions_handler(
10373 State(state_with_stub("unused")),
10374 HeaderMap::new(),
10375 Ok(Json(request)),
10376 )
10377 .await
10378 .expect_err("unsupported field should reject");
10379 let (status, body) = error_json(err).await;
10380 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
10381 assert_eq!(body["error"]["param"], param);
10382 assert_eq!(body["error"]["type"], "invalid_request_error");
10383 }
10384 }
10385
10386 #[tokio::test]
10387 async fn chat_stream_options_include_usage_controls_stream_usage() {
10388 let request = chat_request(json!({
10389 "stream": true,
10390 "stream_options": {"include_usage": true}
10391 }));
10392 let response = chat_completions_handler(
10393 State(state_with_stub("ok")),
10394 HeaderMap::new(),
10395 Ok(Json(request)),
10396 )
10397 .await
10398 .expect("stream response");
10399 assert_eq!(response.status(), AxumStatusCode::OK);
10400 let body = response_text(response).await;
10401 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10402 assert!(
10403 body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
10404 "include_usage=true should emit stream usage: {body}"
10405 );
10406 assert!(
10407 body.contains("\"choices\":[],\"usage\""),
10408 "include_usage=true should use a separate usage chunk: {body}"
10409 );
10410 assert!(
10411 body.contains("\"prompt_tokens\":5"),
10412 "stream usage should come from engine token usage: {body}"
10413 );
10414
10415 let request = chat_request(json!({"stream": true}));
10416 let response = chat_completions_handler(
10417 State(state_with_stub("ok")),
10418 HeaderMap::new(),
10419 Ok(Json(request)),
10420 )
10421 .await
10422 .expect("stream response");
10423 let body = response_text(response).await;
10424 assert!(
10425 !body.contains("\"usage\":{\"prompt_tokens\""),
10426 "stream usage should be omitted unless requested: {body}"
10427 );
10428 }
10429
10430 #[tokio::test]
10431 async fn streaming_chat_does_not_synthesize_whitespace_usage() {
10432 let response = post_json(
10433 router_with_stub_without_stream_usage("ok"),
10434 "/v1/chat/completions",
10435 json!({
10436 "model": "stub-model",
10437 "messages": [{"role": "user", "content": "one two three four"}],
10438 "stream": true,
10439 "stream_options": {"include_usage": true}
10440 }),
10441 )
10442 .await;
10443 assert_eq!(response.status(), AxumStatusCode::OK);
10444 let body = response_text(response).await;
10445 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10446 assert!(
10447 !body.contains("\"usage\":{\"prompt_tokens\""),
10448 "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
10449 );
10450 }
10451
10452 #[test]
10453 fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
10454 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10455 "model": "qwen3",
10456 "messages": [
10457 {"role": "user", "content": "Use the weather tool."},
10458 {
10459 "role": "assistant",
10460 "content": null,
10461 "tool_calls": [{
10462 "id": "call_1",
10463 "type": "function",
10464 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10465 }]
10466 },
10467 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
10468 ],
10469 "tools": [{
10470 "type": "function",
10471 "function": {
10472 "name": "weather",
10473 "description": "Get weather",
10474 "parameters": {
10475 "type": "object",
10476 "properties": {"city": {"type": "string"}},
10477 "required": ["city"]
10478 }
10479 }
10480 }],
10481 "tool_choice": "auto"
10482 }))
10483 .expect("tool request parses");
10484
10485 validate_chat_request(&request).expect("tool request validates");
10486 let internal = convert_chat_request(&request).expect("convert");
10487 assert!(internal.prompt.contains("\"tools\":[{"));
10488 assert!(internal.prompt.contains("\"type\":\"function\""));
10489 assert!(internal.prompt.contains("\"name\":\"weather\""));
10490 assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
10491 assert!(internal.prompt.contains("\"tool_calls\":[{"));
10492 assert!(internal.prompt.contains("\"id\":\"call_1\""));
10493 assert!(internal
10494 .prompt
10495 .contains("<|im_start|>tool\nsunny<|im_end|>"));
10496 assert_eq!(
10497 internal.metadata["openai_tools"][0]["function"]["name"],
10498 "weather"
10499 );
10500 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
10501 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10502 panic!("expected structured chat api_request");
10503 };
10504 assert_eq!(api.messages.len(), 3);
10505 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
10506 assert_eq!(api.messages[2].content, "sunny");
10507 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
10508 assert_eq!(api.tools[0].function.name, "weather");
10509 assert_eq!(
10510 api.tool_choice,
10511 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
10512 );
10513 assert_eq!(
10514 api.messages[1].tool_calls[0].function.arguments,
10515 "{\"city\":\"Paris\"}"
10516 );
10517 }
10518
10519 #[test]
10520 fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
10521 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10522 "model": "served-alias",
10523 "messages": [{"role": "user", "content": "Use the weather tool."}],
10524 "tools": [{
10525 "type": "function",
10526 "function": {
10527 "name": "weather",
10528 "description": "Get weather",
10529 "parameters": {
10530 "type": "object",
10531 "properties": {"city": {"type": "string"}},
10532 "required": ["city"]
10533 }
10534 }
10535 }]
10536 }))
10537 .expect("tool request parses");
10538
10539 validate_chat_request(&request).expect("tool request validates");
10540 let internal = convert_chat_request(&request).expect("convert");
10541 assert!(internal.prompt.contains("\"tools\":[{"));
10542 assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
10543 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
10544 let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
10545 .as_array()
10546 .expect("initial forbidden token list");
10547 assert_eq!(initial_forbidden, &[serde_json::json!(THINK_END_TAG)]);
10548 assert_eq!(
10549 internal.sampling_params.response_format,
10550 ferrum_types::ResponseFormat::Text,
10551 "auto tool choice must preserve native model selection instead of forcing arguments JSON",
10552 );
10553 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10554 panic!("expected structured chat api_request");
10555 };
10556 assert_eq!(
10557 api.tool_choice,
10558 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
10559 );
10560 }
10561
10562 #[test]
10563 fn omitted_tool_choice_uses_native_template_protocol_without_hard_schema() {
10564 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10565 "model": "served-alias",
10566 "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
10567 "tools": [{
10568 "type": "function",
10569 "function": {
10570 "name": "get_weather",
10571 "description": "查询指定城市的当前天气",
10572 "parameters": {
10573 "type": "object",
10574 "properties": {
10575 "city": {"type": "string"},
10576 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
10577 },
10578 "required": ["city"]
10579 }
10580 }
10581 }]
10582 }))
10583 .expect("tool request parses");
10584 let template = ModelChatTemplate::new(
10585 "{% if tools %}<tools>{{ tools | tojson }}</tools>Use <tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
10586 "function-parameter-xml-template",
10587 );
10588
10589 validate_chat_request(&request).expect("tool request validates");
10590 let internal =
10591 convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
10592 .expect("convert");
10593 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
10594 assert_eq!(
10595 internal.sampling_params.response_format,
10596 ferrum_types::ResponseFormat::Text,
10597 );
10598 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
10599 panic!("expected chat API request");
10600 };
10601 assert_eq!(
10602 api.tool_call_protocol,
10603 ferrum_types::ApiToolCallProtocol::FunctionParameterXml,
10604 );
10605 }
10606
10607 #[test]
10608 fn tool_schema_response_format_bounds_unconstrained_strings() {
10609 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10610 "model": "served-alias",
10611 "messages": [{"role": "user", "content": "Use the selected tool."}],
10612 "tools": [{
10613 "type": "function",
10614 "function": {
10615 "name": "get_weather",
10616 "parameters": {
10617 "type": "object",
10618 "properties": {
10619 "city": {"type": "string"},
10620 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
10621 },
10622 "required": ["city"]
10623 }
10624 }
10625 }],
10626 "tool_choice": {
10627 "type": "function",
10628 "function": {"name": "get_weather"}
10629 }
10630 }))
10631 .expect("tool request parses");
10632
10633 validate_chat_request(&request).expect("tool request validates");
10634 let internal = convert_chat_request(&request).expect("convert");
10635 match internal.sampling_params.response_format {
10636 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
10637 let value: serde_json::Value =
10638 serde_json::from_str(schema).expect("schema should be JSON");
10639 assert_eq!(
10640 value["properties"]["city"]["maxLength"],
10641 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
10642 );
10643 assert_eq!(
10644 value["properties"]["unit"]["enum"],
10645 json!(["celsius", "fahrenheit"])
10646 );
10647 assert!(
10648 value["properties"]["unit"]["maxLength"].is_null(),
10649 "enum string should remain finite via enum instead of maxLength: {value}"
10650 );
10651 }
10652 ref other => panic!("expected forced tool json schema, got {other:?}"),
10653 }
10654 }
10655
10656 #[test]
10657 fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
10658 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10659 "model": "served-alias",
10660 "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
10661 "tools": [{
10662 "type": "function",
10663 "function": {
10664 "name": "capture_quality_marker",
10665 "description": "Record one marker.",
10666 "parameters": {
10667 "type": "object",
10668 "properties": {
10669 "marker": {"type": "string", "enum": ["ferrum0401"]},
10670 "checksum": {"type": "string", "enum": ["S0004"]}
10671 },
10672 "required": ["marker", "checksum"]
10673 }
10674 }
10675 }],
10676 "tool_choice": "required"
10677 }))
10678 .expect("tool request parses");
10679
10680 validate_chat_request(&request).expect("tool request validates");
10681 let internal = convert_chat_request(&request).expect("convert");
10682
10683 assert!(
10684 !internal.prompt.contains(
10685 "Output only a single JSON object containing the selected function arguments"
10686 ),
10687 "{}",
10688 internal.prompt
10689 );
10690 assert!(
10691 internal.prompt.contains("\"tool_choice\":\"required\""),
10692 "{}",
10693 internal.prompt
10694 );
10695 assert_eq!(internal.metadata["openai_tool_choice"], "required");
10696 match internal.sampling_params.response_format {
10697 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
10698 assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
10699 assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
10700 }
10701 ref other => panic!("expected forced tool json schema, got {other:?}"),
10702 }
10703 }
10704
10705 #[test]
10706 fn required_tool_choice_suppresses_conflicting_response_format_instruction() {
10707 let request: ChatCompletionsRequest =
10708 serde_json::from_value(required_tool_with_strict_response_format_request(false))
10709 .expect("request parses");
10710
10711 validate_chat_request(&request).expect("request validates");
10712 let internal = convert_chat_request(&request).expect("convert");
10713
10714 assert!(
10715 !internal.prompt.contains("response_format requires"),
10716 "required tool output must not receive a conflicting content-schema instruction: {}",
10717 internal.prompt
10718 );
10719 let ferrum_types::ResponseFormat::JsonSchema(schema) =
10720 internal.sampling_params.response_format
10721 else {
10722 panic!("single required tool must use its argument schema");
10723 };
10724 let schema: Value = serde_json::from_str(&schema).expect("tool schema JSON");
10725 assert!(schema["properties"].get("city").is_some(), "{schema}");
10726 assert!(schema["properties"].get("answer").is_none(), "{schema}");
10727 }
10728
10729 #[test]
10730 fn required_multiple_tools_do_not_force_the_first_tool_schema() {
10731 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10732 "model": "stub-model",
10733 "messages": [{"role": "user", "content": "Use the appropriate tool."}],
10734 "tools": [
10735 {
10736 "type": "function",
10737 "function": {
10738 "name": "weather",
10739 "parameters": {
10740 "type": "object",
10741 "properties": {"city": {"type": "string"}},
10742 "required": ["city"]
10743 }
10744 }
10745 },
10746 {
10747 "type": "function",
10748 "function": {
10749 "name": "calendar",
10750 "parameters": {
10751 "type": "object",
10752 "properties": {"date": {"type": "string"}},
10753 "required": ["date"]
10754 }
10755 }
10756 }
10757 ],
10758 "tool_choice": "required"
10759 }))
10760 .expect("request parses");
10761
10762 validate_chat_request(&request).expect("request validates");
10763 let internal = convert_chat_request(&request).expect("convert");
10764 assert_eq!(
10765 internal.sampling_params.response_format,
10766 ferrum_types::ResponseFormat::Text,
10767 "required permits either declared tool, so guided decoding cannot bind the first tool's arguments"
10768 );
10769 }
10770
10771 #[test]
10772 fn omitted_single_unrelated_tool_keeps_text_response_format() {
10773 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10774 "model": "served-alias",
10775 "messages": [{"role": "user", "content": "讲一个短笑话。"}],
10776 "tools": [{
10777 "type": "function",
10778 "function": {
10779 "name": "get_weather",
10780 "description": "查询指定城市的当前天气",
10781 "parameters": {
10782 "type": "object",
10783 "properties": {"city": {"type": "string"}},
10784 "required": ["city"]
10785 }
10786 }
10787 }]
10788 }))
10789 .expect("tool request parses");
10790
10791 validate_chat_request(&request).expect("tool request validates");
10792 let internal = convert_chat_request(&request).expect("convert");
10793 assert_eq!(
10794 internal.sampling_params.response_format,
10795 ferrum_types::ResponseFormat::Text
10796 );
10797 }
10798
10799 #[test]
10800 fn tool_choice_none_omits_tools_from_model_template_prompt() {
10801 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10802 "model": "served-alias",
10803 "messages": [
10804 {"role": "user", "content": "Use the weather tool if needed."},
10805 {
10806 "role": "assistant",
10807 "content": null,
10808 "tool_calls": [{
10809 "id": "call_1",
10810 "type": "function",
10811 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10812 }]
10813 },
10814 {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
10815 ],
10816 "tools": [{
10817 "type": "function",
10818 "function": {"name": "weather", "parameters": {"type": "object"}}
10819 }],
10820 "tool_choice": "none"
10821 }))
10822 .expect("tool_choice none request parses");
10823 let template = ModelChatTemplate::new(
10824 "{% set tools_in_user_message = true %}{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
10825 "tool-choice-none-template",
10826 );
10827
10828 validate_chat_request(&request).expect("tool_choice none request validates");
10829 let internal = convert_chat_request_with_template_model(
10830 &request,
10831 "served-template-model",
10832 Some(&template),
10833 )
10834 .expect("convert");
10835 assert!(
10836 !internal.prompt.contains("<tools>"),
10837 "tool_choice none must not expose tools to the model template: {}",
10838 internal.prompt
10839 );
10840 assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
10841 assert_eq!(
10842 internal.metadata["openai_tools"][0]["function"]["name"],
10843 "weather"
10844 );
10845 assert_eq!(internal.metadata["openai_tool_choice"], "none");
10846 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10847 panic!("expected structured chat api_request");
10848 };
10849 assert_eq!(api.tools[0].function.name, "weather");
10850 assert_eq!(
10851 api.tool_choice,
10852 Some(ferrum_types::ApiToolChoice::Mode("none".into()))
10853 );
10854 }
10855
10856 #[test]
10857 fn specific_tool_choice_parses_into_structured_api_request() {
10858 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10859 "model": "qwen3",
10860 "messages": [{"role": "user", "content": "Use the selected tool."}],
10861 "tools": [
10862 {
10863 "type": "function",
10864 "function": {"name": "weather", "parameters": {"type": "object"}}
10865 },
10866 {
10867 "type": "function",
10868 "function": {"name": "calendar", "parameters": {"type": "object"}}
10869 }
10870 ],
10871 "tool_choice": {
10872 "type": "function",
10873 "function": {"name": "weather"}
10874 }
10875 }))
10876 .expect("specific tool_choice request parses");
10877
10878 validate_chat_request(&request).expect("specific tool_choice validates");
10879 let internal = convert_chat_request(&request).expect("convert");
10880 assert!(internal.prompt.contains("\"tool_choice\":{"));
10881 assert!(internal.prompt.contains("\"name\":\"weather\""));
10882 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10883 panic!("expected structured chat api_request");
10884 };
10885 assert_eq!(
10886 api.tool_choice,
10887 Some(ferrum_types::ApiToolChoice::Function {
10888 tool_type: "function".to_string(),
10889 function: ferrum_types::ApiToolChoiceFunction {
10890 name: "weather".to_string()
10891 },
10892 })
10893 );
10894
10895 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
10896 "model": "qwen3",
10897 "messages": [{"role": "user", "content": "Use the selected tool."}],
10898 "tools": [{
10899 "type": "function",
10900 "function": {"name": "weather", "parameters": {"type": "object"}}
10901 }],
10902 "tool_choice": {
10903 "type": "function",
10904 "function": {"name": "calendar"}
10905 }
10906 }))
10907 .expect("invalid specific tool_choice request parses");
10908 let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
10909 match err {
10910 ServerError::InvalidRequest { param, .. } => {
10911 assert_eq!(param.as_deref(), Some("tool_choice"));
10912 }
10913 other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
10914 }
10915 }
10916
10917 #[test]
10918 fn legacy_function_role_messages_parse_into_structured_api_request() {
10919 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10920 "model": "mystery-model",
10921 "messages": [
10922 {"role": "user", "content": "Call weather."},
10923 {
10924 "role": "assistant",
10925 "content": null,
10926 "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10927 },
10928 {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
10929 ],
10930 "functions": [{
10931 "name": "weather",
10932 "parameters": {
10933 "type": "object",
10934 "properties": {"city": {"type": "string"}},
10935 "required": ["city"]
10936 }
10937 }],
10938 "function_call": "auto"
10939 }))
10940 .expect("legacy function request parses");
10941
10942 validate_chat_request(&request).expect("legacy function request validates");
10943 let internal = convert_chat_request(&request).expect("convert");
10944 assert!(
10945 internal
10946 .prompt
10947 .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
10948 "legacy function role should be preserved in fallback template: {}",
10949 internal.prompt
10950 );
10951 assert_eq!(
10952 internal.metadata["openai_legacy_functions"][0]["name"],
10953 "weather"
10954 );
10955 assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
10956 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10957 panic!("expected structured chat api_request");
10958 };
10959 assert_eq!(api.messages.len(), 3);
10960 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
10961 assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
10962 assert_eq!(
10963 api.messages[1]
10964 .function_call
10965 .as_ref()
10966 .map(|call| call.name.as_str()),
10967 Some("weather")
10968 );
10969 assert_eq!(api.legacy_functions[0].name, "weather");
10970 assert_eq!(
10971 api.legacy_function_call,
10972 Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
10973 );
10974 }
10975
10976 #[test]
10977 fn specific_legacy_function_call_parses_into_structured_api_request() {
10978 let request: ChatCompletionsRequest = serde_json::from_value(json!({
10979 "model": "mystery-model",
10980 "messages": [{"role": "user", "content": "Use the selected function."}],
10981 "functions": [
10982 {"name": "weather", "parameters": {"type": "object"}},
10983 {"name": "calendar", "parameters": {"type": "object"}}
10984 ],
10985 "function_call": {"name": "weather"}
10986 }))
10987 .expect("specific function_call request parses");
10988
10989 validate_chat_request(&request).expect("specific function_call validates");
10990 let internal = convert_chat_request(&request).expect("convert");
10991 assert!(internal.prompt.contains("\"function_call\":{"));
10992 assert!(internal.prompt.contains("\"name\":\"weather\""));
10993 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
10994 panic!("expected structured chat api_request");
10995 };
10996 assert_eq!(
10997 api.legacy_function_call,
10998 Some(ferrum_types::ApiFunctionCallChoice::Function {
10999 name: "weather".to_string(),
11000 })
11001 );
11002
11003 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
11004 "model": "mystery-model",
11005 "messages": [{"role": "user", "content": "Use the selected function."}],
11006 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
11007 "function_call": {"name": "calendar"}
11008 }))
11009 .expect("invalid specific function_call request parses");
11010 let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
11011 match err {
11012 ServerError::InvalidRequest { param, .. } => {
11013 assert_eq!(param.as_deref(), Some("function_call"));
11014 }
11015 other => panic!("expected invalid_request_error for function_call, got {other:?}"),
11016 }
11017 }
11018
11019 #[test]
11020 fn stream_text_delta_handles_unicode_boundaries() {
11021 let mut sent_len = 0usize;
11022 assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
11023 assert_eq!(sent_len, "你好".len());
11024 assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
11025 assert_eq!(sent_len, "你好世界".len());
11026 }
11027
11028 #[test]
11029 fn stream_text_delta_recovers_from_non_boundary_offset() {
11030 let mut sent_len = 1usize;
11031 assert_eq!(stream_text_delta("你好", &mut sent_len), "");
11032 assert_eq!(sent_len, "你好".len());
11033 }
11034
11035 #[test]
11036 fn assistant_tool_call_serializes_openai_shape() {
11037 let message = ChatMessage {
11038 role: MessageRole::Assistant,
11039 content: String::new(),
11040 reasoning: None,
11041 name: None,
11042 tool_calls: Some(vec![ChatToolCall {
11043 index: None,
11044 id: "call_1".to_string(),
11045 tool_type: "function".to_string(),
11046 function: ChatFunctionCall {
11047 name: "weather".to_string(),
11048 arguments: "{\"city\":\"Paris\"}".to_string(),
11049 },
11050 }]),
11051 tool_call_id: None,
11052 function_call: None,
11053 };
11054 let value = serde_json::to_value(message).expect("serialize");
11055 assert_eq!(value["role"], "assistant");
11056 assert_eq!(value["tool_calls"][0]["type"], "function");
11057 assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
11058 }
11059
11060 #[test]
11061 fn unsupported_multimodal_content_is_not_silently_dropped() {
11062 let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
11063 "model": "stub-model",
11064 "messages": [{
11065 "role": "user",
11066 "content": [
11067 {"type": "text", "text": "describe this"},
11068 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
11069 ]
11070 }]
11071 }))
11072 .expect_err("unsupported content part should fail parsing");
11073 assert!(
11074 err.to_string()
11075 .contains("unsupported message content part type"),
11076 "unexpected error: {err}"
11077 );
11078 }
11079
11080 #[tokio::test]
11081 async fn completions_endpoint_uses_stub_engine() {
11082 let request = CompletionsRequest {
11083 model: "stub-model".to_string(),
11084 prompt: CompletionPrompt::Text("complete me".to_string()),
11085 max_tokens: Some(8),
11086 temperature: Some(0.0),
11087 top_p: None,
11088 n: None,
11089 stream: None,
11090 stop: None,
11091 logprobs: None,
11092 logit_bias: None,
11093 };
11094 let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
11095 .await
11096 .expect("completion response");
11097 assert_eq!(response.status(), AxumStatusCode::OK);
11098 let body = response_json(response).await;
11099 assert_eq!(body["object"], "text_completion");
11100 assert_eq!(body["choices"][0]["text"], "done");
11101 assert_eq!(body["usage"]["prompt_tokens"], 7);
11102 assert_eq!(body["usage"]["completion_tokens"], 2);
11103 }
11104
11105 #[tokio::test]
11106 async fn route_completions_rejects_non_string_prompt_with_field_param() {
11107 for prompt in [
11108 json!(["a", "b"]),
11109 json!({"text": "complete me"}),
11110 Value::Null,
11111 ] {
11112 let response = post_json(
11113 router_with_stub("unused"),
11114 "/v1/completions",
11115 json!({
11116 "model": "stub-model",
11117 "prompt": prompt
11118 }),
11119 )
11120 .await;
11121 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11122 let body = response_json(response).await;
11123 assert_eq!(body["error"]["type"], "invalid_request_error");
11124 assert_eq!(body["error"]["param"], "prompt");
11125 }
11126
11127 let response = post_json(
11128 router_with_stub("unused"),
11129 "/v1/completions",
11130 json!({"model": "stub-model"}),
11131 )
11132 .await;
11133 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11134 let body = response_json(response).await;
11135 assert_eq!(body["error"]["type"], "invalid_request_error");
11136 assert_eq!(body["error"]["param"], "prompt");
11137 }
11138
11139 #[tokio::test]
11140 async fn stream_options_without_stream_is_invalid() {
11141 let request = chat_request(json!({"stream_options": {"include_usage": true}}));
11142 let err = chat_completions_handler(
11143 State(state_with_stub("unused")),
11144 HeaderMap::new(),
11145 Ok(Json(request)),
11146 )
11147 .await
11148 .expect_err("stream_options without stream should reject");
11149 let (status, body) = error_json(err).await;
11150 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
11151 assert_eq!(body["error"]["param"], "stream_options");
11152 assert_eq!(body["error"]["type"], "invalid_request_error");
11153 }
11154
11155 #[tokio::test]
11156 async fn unknown_stream_option_is_rejected_instead_of_ignored() {
11157 let response = post_json(
11158 router_with_stub("unused"),
11159 "/v1/chat/completions",
11160 json!({
11161 "model": "stub-model",
11162 "messages": [{"role": "user", "content": "hello"}],
11163 "stream": true,
11164 "stream_options": {"continuous_usage_stats": true}
11165 }),
11166 )
11167 .await;
11168
11169 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11170 let body = response_json(response).await;
11171 assert_eq!(body["error"]["type"], "invalid_request_error");
11172 assert!(
11173 body["error"]["message"]
11174 .as_str()
11175 .unwrap_or_default()
11176 .contains("invalid chat completions request"),
11177 "body: {body}"
11178 );
11179 }
11180
11181 #[tokio::test]
11182 async fn json_object_rejects_markdown_fence_instead_of_repairing() {
11183 let request = chat_request(json!({
11184 "response_format": {"type": "json_object"}
11185 }));
11186 let err = chat_completions_handler(
11187 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
11188 HeaderMap::new(),
11189 Ok(Json(request)),
11190 )
11191 .await
11192 .expect_err("fenced json_object must fail");
11193 let (status, body) = error_json(err).await;
11194 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11195 assert_eq!(body["error"]["type"], "internal_server_error");
11196 assert!(body["error"]["message"]
11197 .as_str()
11198 .unwrap_or_default()
11199 .contains("response_format.json_object: invalid JSON"));
11200 }
11201
11202 #[tokio::test]
11203 async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
11204 let response = post_json(
11205 router_with_stub_stream_chunks(&[
11206 "<think>\n好的,我需要输出 JSON。",
11207 "\n</think>\n\n",
11208 "{\"name\":\"李四\",\"age\":30}",
11209 ]),
11210 "/v1/chat/completions",
11211 json!({
11212 "model": "stub-model",
11213 "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
11214 "stream": true,
11215 "response_format": {"type": "json_object"}
11216 }),
11217 )
11218 .await;
11219 assert_eq!(response.status(), AxumStatusCode::OK);
11220 let body = response_text(response).await;
11221 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11222 assert!(
11223 body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
11224 "stream should emit clean JSON content: {body}"
11225 );
11226 assert!(
11227 body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
11228 "stream should keep thinking in reasoning field: {body}"
11229 );
11230 assert!(
11231 !body.contains(r#""content":"<think"#)
11232 && !body.contains(r#""content":"好的"#)
11233 && !body.contains(r#""content":"我需要"#),
11234 "thinking text must not leak as streamed content: {body}"
11235 );
11236 }
11237
11238 #[tokio::test]
11239 async fn json_object_rejects_non_json_model_output() {
11240 let request = chat_request(json!({
11241 "response_format": {"type": "json_object"}
11242 }));
11243 let err = chat_completions_handler(
11244 State(state_with_stub("not json")),
11245 HeaderMap::new(),
11246 Ok(Json(request)),
11247 )
11248 .await
11249 .expect_err("invalid json_object must fail");
11250 let (status, body) = error_json(err).await;
11251 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11252 assert_eq!(body["error"]["type"], "internal_server_error");
11253 assert!(body["error"]["message"]
11254 .as_str()
11255 .unwrap_or_default()
11256 .contains("response_format.json_object"));
11257 }
11258
11259 #[test]
11260 fn one_of_strict_json_schema_reaches_hard_decoder() {
11261 let request = chat_request(json!({
11262 "response_format": {
11263 "type": "json_schema",
11264 "json_schema": {
11265 "name": "unsupported",
11266 "strict": true,
11267 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
11268 }
11269 }
11270 }));
11271 validate_chat_request(&request).expect("oneOf strict schema should validate");
11272 let internal = convert_chat_request(&request).expect("convert oneOf strict schema");
11273 let ferrum_types::ResponseFormat::JsonSchema(schema) =
11274 internal.sampling_params.response_format
11275 else {
11276 panic!("strict schema did not reach hard decoder");
11277 };
11278 assert_eq!(
11279 serde_json::from_str::<serde_json::Value>(&schema).unwrap()["oneOf"],
11280 json!([{"type": "string"}, {"type": "integer"}])
11281 );
11282 let schema = serde_json::from_str::<serde_json::Value>(&schema).unwrap();
11283 validate_json_text_against_schema(&schema, r#""answer""#)
11284 .expect("oneOf string branch should pass final validation");
11285 validate_json_text_against_schema(&schema, "7")
11286 .expect("oneOf integer branch should pass final validation");
11287 assert!(validate_json_text_against_schema(&schema, "true").is_err());
11288 }
11289
11290 #[tokio::test]
11291 async fn missing_json_schema_schema_rejects_with_field_param() {
11292 let request = chat_request(json!({
11293 "response_format": {
11294 "type": "json_schema",
11295 "json_schema": {
11296 "name": "missing_schema",
11297 "strict": true
11298 }
11299 }
11300 }));
11301 let err = chat_completions_handler(
11302 State(state_with_stub("unused")),
11303 HeaderMap::new(),
11304 Ok(Json(request)),
11305 )
11306 .await
11307 .expect_err("missing strict schema should reject");
11308 let (status, body) = error_json(err).await;
11309 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
11310 assert_eq!(body["error"]["param"], "response_format.json_schema");
11311 assert_eq!(body["error"]["type"], "invalid_request_error");
11312 assert!(body["error"]["message"]
11313 .as_str()
11314 .unwrap()
11315 .contains("schema is required"));
11316 }
11317
11318 #[test]
11319 fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
11320 let request = chat_request(json!({
11321 "response_format": {
11322 "type": "json_schema",
11323 "json_schema": {
11324 "name": "best_effort",
11325 "strict": false,
11326 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
11327 }
11328 }
11329 }));
11330
11331 validate_chat_request(&request).expect("non-strict schema should not boundary reject");
11332 let internal = convert_chat_request(&request).expect("convert non-strict schema");
11333 assert!(
11334 internal
11335 .prompt
11336 .contains("response_format requires a single valid JSON value"),
11337 "response_format instruction should reach the model prompt: {}",
11338 internal.prompt
11339 );
11340 assert!(
11341 internal.prompt.contains("\"oneOf\""),
11342 "schema should reach the model prompt: {}",
11343 internal.prompt
11344 );
11345 assert_eq!(
11346 internal.sampling_params.response_format,
11347 ferrum_types::ResponseFormat::Text,
11348 "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
11349 );
11350 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
11351 panic!("expected structured chat api_request");
11352 };
11353 assert_eq!(
11354 api.response_format
11355 .as_ref()
11356 .and_then(|format| format.json_schema.as_ref())
11357 .and_then(|schema| schema.strict),
11358 Some(false)
11359 );
11360 }
11361
11362 #[test]
11363 fn json_object_response_format_instruction_reaches_model_prompt() {
11364 let request = chat_request(json!({
11365 "response_format": {"type": "json_object"}
11366 }));
11367
11368 let internal = convert_chat_request(&request).expect("convert json_object");
11369 assert!(
11370 internal
11371 .prompt
11372 .contains("response_format requires a single valid JSON object"),
11373 "response_format instruction should reach the model prompt: {}",
11374 internal.prompt
11375 );
11376 assert!(
11377 internal.prompt.contains("Output only JSON"),
11378 "JSON-only instruction should reach the model prompt: {}",
11379 internal.prompt
11380 );
11381 assert_eq!(
11382 internal.sampling_params.response_format,
11383 ferrum_types::ResponseFormat::JsonObject,
11384 "json_object must reach the tokenizer-aware hard decoder"
11385 );
11386 assert_eq!(
11387 internal.sampling_params.structured_output_start,
11388 StructuredOutputStart::Immediate
11389 );
11390 }
11391
11392 #[test]
11393 fn json_object_thinking_template_activates_after_typed_end_delimiter() {
11394 let request = chat_request(json!({
11395 "response_format": {"type": "json_object"}
11396 }));
11397 let template = ModelChatTemplate::new(
11398 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
11399 "thinking-test-template",
11400 );
11401
11402 assert_eq!(
11403 template.reasoning_protocol,
11404 ModelReasoningProtocol::PromptOpened
11405 );
11406 let internal =
11407 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11408 .expect("convert thinking json_object");
11409
11410 assert!(internal.prompt.ends_with("<assistant><think>\n"));
11411 assert!(internal
11412 .prompt
11413 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
11414 assert!(
11415 !internal.prompt.contains(THINK_END_TAG),
11416 "the instruction must not echo the typed end delimiter: {}",
11417 internal.prompt
11418 );
11419 assert_eq!(
11420 internal.sampling_params.structured_output_start,
11421 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
11422 );
11423 assert_eq!(
11424 internal.sampling_params.response_completion_boundary,
11425 ResponseCompletionBoundary::AfterDelimiterAndPayload {
11426 delimiter: THINK_END_TAG.to_string(),
11427 alternate_envelope: None,
11428 }
11429 );
11430 }
11431
11432 #[test]
11433 fn json_object_model_generated_thinking_activates_after_typed_end_delimiter() {
11434 let request = chat_request(json!({
11435 "response_format": {"type": "json_object"},
11436 "chat_template_kwargs": {"enable_thinking": true}
11437 }));
11438 let template = ModelChatTemplate::new(
11439 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
11440 "qwen3-model-generated-thinking-template",
11441 );
11442
11443 let internal =
11444 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11445 .expect("convert model-generated thinking json_object");
11446
11447 assert!(!has_unclosed_thinking_block(&internal.prompt));
11448 assert!(internal.prompt.ends_with("<assistant>"));
11449 assert!(internal
11450 .prompt
11451 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
11452 assert!(
11453 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
11454 "the instruction must not teach the model the typed reasoning delimiter: {}",
11455 internal.prompt
11456 );
11457 assert_eq!(
11458 internal.sampling_params.structured_output_start,
11459 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
11460 );
11461 assert_eq!(
11462 internal.sampling_params.response_completion_boundary,
11463 ResponseCompletionBoundary::AfterDelimiterAndPayload {
11464 delimiter: THINK_END_TAG.to_string(),
11465 alternate_envelope: None,
11466 }
11467 );
11468 }
11469
11470 #[test]
11471 fn strict_schema_model_generated_thinking_does_not_echo_typed_delimiter() {
11472 let request = chat_request(json!({
11473 "response_format": {
11474 "type": "json_schema",
11475 "json_schema": {
11476 "name": "reasoning_result",
11477 "strict": true,
11478 "schema": {
11479 "type": "object",
11480 "properties": {
11481 "result": {"type": "string", "const": "G00-c21-schema-OK"}
11482 },
11483 "required": ["result"],
11484 "additionalProperties": false
11485 }
11486 }
11487 },
11488 "chat_template_kwargs": {"enable_thinking": true}
11489 }));
11490 let template = ModelChatTemplate::new(
11491 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
11492 "qwen3-model-generated-thinking-template",
11493 );
11494
11495 let internal =
11496 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11497 .expect("convert model-generated thinking strict schema");
11498
11499 assert!(!has_unclosed_thinking_block(&internal.prompt));
11500 assert!(internal.prompt.ends_with("<assistant>"));
11501 assert!(internal.prompt.contains("G00-c21-schema-OK"));
11502 assert!(
11503 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
11504 "the instruction must not teach the model the typed reasoning delimiter: {}",
11505 internal.prompt
11506 );
11507 assert_eq!(
11508 internal.sampling_params.structured_output_start,
11509 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
11510 );
11511 assert_eq!(
11512 internal.sampling_params.response_completion_boundary,
11513 ResponseCompletionBoundary::AfterDelimiterAndPayload {
11514 delimiter: THINK_END_TAG.to_string(),
11515 alternate_envelope: None,
11516 }
11517 );
11518 }
11519
11520 #[test]
11521 fn json_object_model_generated_thinking_hard_off_starts_immediately() {
11522 let request = chat_request(json!({
11523 "response_format": {"type": "json_object"},
11524 "chat_template_kwargs": {"enable_thinking": false}
11525 }));
11526 let template = ModelChatTemplate::new(
11527 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
11528 "qwen3-model-generated-thinking-template",
11529 );
11530
11531 let internal =
11532 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11533 .expect("convert disabled model-generated thinking json_object");
11534
11535 assert_eq!(
11536 internal.sampling_params.structured_output_start,
11537 StructuredOutputStart::Immediate
11538 );
11539 assert_eq!(
11540 internal.sampling_params.response_completion_boundary,
11541 ResponseCompletionBoundary::Immediate
11542 );
11543 assert!(internal.prompt.contains("no chain-of-thought"));
11544 }
11545
11546 #[test]
11547 fn response_completion_contract_is_set_on_text_thinking_template() {
11548 let request = chat_request(json!({}));
11549 let template = ModelChatTemplate::new(
11550 "{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
11551 "thinking-test-template",
11552 );
11553
11554 let internal =
11555 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11556 .expect("convert thinking text request");
11557
11558 assert_eq!(
11559 internal.sampling_params.structured_output_start,
11560 StructuredOutputStart::Immediate
11561 );
11562 assert_eq!(
11563 internal.sampling_params.response_completion_boundary,
11564 ResponseCompletionBoundary::AfterDelimiterAndPayload {
11565 delimiter: THINK_END_TAG.to_string(),
11566 alternate_envelope: None,
11567 }
11568 );
11569 }
11570
11571 #[test]
11572 fn thinking_tool_request_compiles_typed_envelope_into_completion_contract() {
11573 let request = chat_request(json!({
11574 "tools": [{
11575 "type": "function",
11576 "function": {
11577 "name": "weather",
11578 "parameters": {
11579 "type": "object",
11580 "properties": {"city": {"type": "string"}},
11581 "required": ["city"]
11582 }
11583 }
11584 }]
11585 }));
11586 let template = ModelChatTemplate::new(
11587 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
11588 "thinking-tool-template",
11589 );
11590
11591 let internal =
11592 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
11593 .expect("convert thinking tool request");
11594
11595 assert_eq!(
11596 internal.sampling_params.response_completion_boundary,
11597 ResponseCompletionBoundary::AfterDelimiterAndPayload {
11598 delimiter: THINK_END_TAG.to_string(),
11599 alternate_envelope: Some(ferrum_types::ResponseCompletionEnvelope {
11600 open_token_text: "<tool_call>".to_string(),
11601 close_token_text: "</tool_call>".to_string(),
11602 max_envelopes: 32,
11603 }),
11604 }
11605 );
11606 }
11607
11608 #[test]
11609 fn strict_json_schema_response_format_uses_guided_sampling_mode() {
11610 let request = chat_request(json!({
11611 "response_format": {
11612 "type": "json_schema",
11613 "json_schema": {
11614 "name": "answer",
11615 "strict": true,
11616 "schema": {
11617 "type": "object",
11618 "properties": {"answer": {"type": "string"}},
11619 "required": ["answer"]
11620 }
11621 }
11622 }
11623 }));
11624
11625 let internal = convert_chat_request(&request).expect("convert strict json_schema");
11626 assert!(
11627 internal
11628 .prompt
11629 .contains("response_format requires a single valid JSON value"),
11630 "response_format instruction should reach the model prompt: {}",
11631 internal.prompt
11632 );
11633 let ferrum_types::ResponseFormat::JsonSchema(schema) =
11634 internal.sampling_params.response_format
11635 else {
11636 panic!(
11637 "strict json_schema must reach guided decoding, got {:?}",
11638 internal.sampling_params.response_format
11639 );
11640 };
11641 let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
11642 assert_eq!(schema["type"], "object");
11643 assert_eq!(schema["properties"]["answer"]["type"], "string");
11644 assert_eq!(schema["required"], json!(["answer"]));
11645 }
11646
11647 #[tokio::test]
11648 async fn strict_json_schema_validates_non_streaming_response() {
11649 let request = chat_request(json!({
11650 "response_format": {
11651 "type": "json_schema",
11652 "json_schema": {
11653 "name": "answer",
11654 "strict": true,
11655 "schema": {
11656 "type": "object",
11657 "properties": {"answer": {"type": "string"}},
11658 "required": ["answer"]
11659 }
11660 }
11661 }
11662 }));
11663 let response = chat_completions_handler(
11664 State(state_with_stub("{\"answer\":\"yes\"}")),
11665 HeaderMap::new(),
11666 Ok(Json(request)),
11667 )
11668 .await
11669 .expect("strict response");
11670 assert_eq!(response.status(), AxumStatusCode::OK);
11671 let body = response_json(response).await;
11672 assert_eq!(
11673 body["choices"][0]["message"]["content"],
11674 "{\"answer\":\"yes\"}"
11675 );
11676 }
11677
11678 #[tokio::test]
11679 async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
11680 let request = chat_request(json!({
11681 "response_format": {
11682 "type": "json_schema",
11683 "json_schema": {
11684 "name": "answer",
11685 "strict": true,
11686 "schema": {
11687 "type": "object",
11688 "properties": {"answer": {"type": "string"}},
11689 "required": ["answer"]
11690 }
11691 }
11692 }
11693 }));
11694 let response = chat_completions_handler(
11695 State(state_with_stub(
11696 "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
11697 )),
11698 HeaderMap::new(),
11699 Ok(Json(request)),
11700 )
11701 .await
11702 .expect("strict response with reasoning");
11703 assert_eq!(response.status(), AxumStatusCode::OK);
11704 let body = response_json(response).await;
11705 assert_eq!(
11706 body["choices"][0]["message"]["content"],
11707 "{\"answer\":\"yes\"}"
11708 );
11709 assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
11710 }
11711
11712 #[tokio::test]
11713 async fn strict_json_schema_validates_streaming_final_response() {
11714 let response = post_json(
11715 router_with_stub("{\"answer\":\"yes\"}"),
11716 "/v1/chat/completions",
11717 json!({
11718 "model": "stub-model",
11719 "messages": [{"role": "user", "content": "Return an answer object."}],
11720 "stream": true,
11721 "response_format": {
11722 "type": "json_schema",
11723 "json_schema": {
11724 "name": "answer",
11725 "strict": true,
11726 "schema": {
11727 "type": "object",
11728 "properties": {"answer": {"type": "string"}},
11729 "required": ["answer"]
11730 }
11731 }
11732 }
11733 }),
11734 )
11735 .await;
11736 assert_eq!(response.status(), AxumStatusCode::OK);
11737 let body = response_text(response).await;
11738 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11739 assert!(
11740 body.contains("\\\"answer\\\":\\\"yes\\\""),
11741 "strict streaming content missing: {body}"
11742 );
11743 assert!(
11744 !body.contains("\"error\""),
11745 "valid strict streaming response should not emit error: {body}"
11746 );
11747 }
11748
11749 #[tokio::test]
11750 async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
11751 let response = post_json(
11752 router_with_stub_stream_chunks(&[
11753 "<think>\nreasoning",
11754 "\n</think>\n\n",
11755 "{\"answer\":\"yes\"}",
11756 ]),
11757 "/v1/chat/completions",
11758 json!({
11759 "model": "stub-model",
11760 "messages": [{"role": "user", "content": "Return an answer object."}],
11761 "stream": true,
11762 "response_format": {
11763 "type": "json_schema",
11764 "json_schema": {
11765 "name": "answer",
11766 "strict": true,
11767 "schema": {
11768 "type": "object",
11769 "properties": {"answer": {"type": "string"}},
11770 "required": ["answer"]
11771 }
11772 }
11773 }
11774 }),
11775 )
11776 .await;
11777 assert_eq!(response.status(), AxumStatusCode::OK);
11778 let body = response_text(response).await;
11779 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11780 assert!(
11781 body.contains("\\\"answer\\\":\\\"yes\\\""),
11782 "strict streaming content missing: {body}"
11783 );
11784 assert!(
11785 body.contains(r#""reasoning":"\nreasoning\n""#),
11786 "strict streaming should keep reasoning separate: {body}"
11787 );
11788 assert!(
11789 !body.contains("\"error\""),
11790 "valid strict streaming response should not emit error: {body}"
11791 );
11792 }
11793
11794 #[tokio::test]
11795 async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
11796 let response = post_json(
11797 router_with_stub("not json"),
11798 "/v1/chat/completions",
11799 json!({
11800 "model": "stub-model",
11801 "messages": [{"role": "user", "content": "Return an answer object."}],
11802 "stream": true,
11803 "response_format": {
11804 "type": "json_schema",
11805 "json_schema": {
11806 "name": "answer",
11807 "strict": true,
11808 "schema": {
11809 "type": "object",
11810 "properties": {"answer": {"type": "string"}},
11811 "required": ["answer"]
11812 }
11813 }
11814 }
11815 }),
11816 )
11817 .await;
11818 assert_eq!(response.status(), AxumStatusCode::OK);
11819 let body = response_text(response).await;
11820 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11821 assert!(
11822 body.contains("\"type\":\"internal_server_error\""),
11823 "strict streaming validation failure should emit OpenAI error: {body}"
11824 );
11825 assert!(
11826 body.contains("\"param\":\"response_format.json_schema\""),
11827 "strict streaming validation error should identify schema param: {body}"
11828 );
11829 assert!(
11830 body.contains("invalid JSON"),
11831 "strict streaming validation should report invalid JSON: {body}"
11832 );
11833 assert!(
11834 !body.contains("not json"),
11835 "strict streaming must not emit invalid partial deltas before validation failure: {body}"
11836 );
11837 }
11838
11839 #[tokio::test]
11840 async fn route_strict_json_schema_supported_schema_passes_100_runs() {
11841 let request_body = json!({
11842 "model": "stub-model",
11843 "messages": [{"role": "user", "content": "Return an answer object."}],
11844 "response_format": {
11845 "type": "json_schema",
11846 "json_schema": {
11847 "name": "answer",
11848 "strict": true,
11849 "schema": {
11850 "type": "object",
11851 "properties": {"answer": {"type": "string"}},
11852 "required": ["answer"]
11853 }
11854 }
11855 }
11856 });
11857 let router = router_with_stub("{\"answer\":\"yes\"}");
11858 for run in 0..100 {
11859 let response =
11860 post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
11861 assert_eq!(
11862 response.status(),
11863 AxumStatusCode::OK,
11864 "strict schema run {run} returned non-200"
11865 );
11866 let body = response_json(response).await;
11867 let content = body["choices"][0]["message"]["content"]
11868 .as_str()
11869 .unwrap_or("");
11870 assert_eq!(
11871 content, "{\"answer\":\"yes\"}",
11872 "strict schema run {run} returned unexpected content"
11873 );
11874 let parsed: serde_json::Value =
11875 serde_json::from_str(content).expect("strict content JSON");
11876 assert_eq!(parsed["answer"], "yes");
11877 }
11878 }
11879
11880 #[test]
11881 fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
11882 let cache = CacheRuntimeState::default();
11883 let policy = CachePolicy {
11884 prefix_cache_enabled: true,
11885 session_cache_mode: "memory".to_string(),
11886 session_cache_max_entries: 128,
11887 session_cache_max_tokens: 4096,
11888 };
11889 cache.record_prefix_prompt("alpha beta gamma", &policy);
11890 cache.record_prefix_prompt("alpha beta delta", &policy);
11891
11892 let engine_snapshot = json!({
11893 "position": "real-kv-reuse",
11894 "source": "llama-family-paged-block-prefix-cache",
11895 "enabled": true,
11896 "hits": 7,
11897 "misses": 3,
11898 "evictions": 1,
11899 "saved_prefill_tokens": 64,
11900 "entries": 5,
11901 "bytes": 8192,
11902 "block_size": 16,
11903 "kv_dtype": "fp16",
11904 "selected_pipeline_mode": "batch",
11905 "selected_stage_bridge": "host",
11906 "stage_count": 2,
11907 });
11908
11909 let health = cache.health_json(&policy, Some(&engine_snapshot));
11910 let prefix = &health["prefix_cache"];
11911 assert_eq!(prefix["position"], "real-kv-reuse");
11912 assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
11913 assert_eq!(prefix["hits"], 7);
11914 assert_eq!(prefix["misses"], 3);
11915 assert_eq!(prefix["evictions"], 1);
11916 assert_eq!(prefix["saved_prefill_tokens"], 64);
11917 assert_eq!(prefix["entries"], 5);
11918 assert_eq!(prefix["bytes"], 8192);
11919 assert_eq!(prefix["block_size"], 16);
11920 assert_eq!(prefix["kv_dtype"], "fp16");
11921 assert_eq!(prefix["selected_pipeline_mode"], "batch");
11922 assert_eq!(prefix["selected_stage_bridge"], "host");
11923 assert_eq!(prefix["stage_count"], 2);
11924
11925 let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
11926 assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
11927 assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
11928 assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
11929 assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
11930 assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
11931 }
11932
11933 #[tokio::test]
11934 async fn strict_json_schema_invalid_model_output_fails_before_response() {
11935 let request = chat_request(json!({
11936 "response_format": {
11937 "type": "json_schema",
11938 "json_schema": {
11939 "name": "answer",
11940 "strict": true,
11941 "schema": {
11942 "type": "object",
11943 "properties": {"answer": {"type": "string"}},
11944 "required": ["answer"]
11945 }
11946 }
11947 }
11948 }));
11949 let err = chat_completions_handler(
11950 State(state_with_stub("not json")),
11951 HeaderMap::new(),
11952 Ok(Json(request)),
11953 )
11954 .await
11955 .expect_err("invalid strict response should fail");
11956 let (status, body) = error_json(err).await;
11957 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11958 assert_eq!(body["error"]["type"], "internal_server_error");
11959 assert!(body["error"]["message"]
11960 .as_str()
11961 .unwrap()
11962 .contains("json_schema.strict"));
11963 }
11964
11965 #[tokio::test]
11966 async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
11967 let request = chat_request(json!({
11968 "response_format": {
11969 "type": "json_schema",
11970 "json_schema": {
11971 "name": "answer",
11972 "strict": true,
11973 "schema": {
11974 "type": "object",
11975 "properties": {"answer": {"type": "string"}},
11976 "required": ["answer"]
11977 }
11978 }
11979 }
11980 }));
11981 let err = chat_completions_handler(
11982 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
11983 HeaderMap::new(),
11984 Ok(Json(request)),
11985 )
11986 .await
11987 .expect_err("strict schema should fail fenced JSON instead of repairing it");
11988 let (status, body) = error_json(err).await;
11989 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
11990 assert_eq!(body["error"]["type"], "internal_server_error");
11991 assert!(body["error"]["message"]
11992 .as_str()
11993 .unwrap()
11994 .contains("json_schema.strict: invalid JSON"));
11995 }
11996}