1use crate::{
7 chat_template::{
8 render_chat_prompt_with_model_template_options_and_compatibility,
9 render_chat_prompt_with_tools_and_model_template_compatibility, ChatTemplateOptions,
10 ModelChatTemplate, ModelReasoningProtocol, ReasoningEffort,
11 },
12 model_registry::{LoraAdapterModel, ServedModelKind, ServedModelRegistry},
13 openai::*,
14 traits::HttpServer,
15 types::*,
16};
17use async_trait::async_trait;
18use axum::{
19 extract::{multipart::MultipartRejection, rejection::JsonRejection, State},
20 http::{HeaderMap, StatusCode as AxumStatusCode},
21 response::{sse::Event, IntoResponse, Response, Sse},
22 routing::{get, post},
23 Json, Router,
24};
25use ferrum_bench_core::{
26 BenchmarkRequestCorrelation, BENCHMARK_CELL_ID_HEADER, BENCHMARK_PHASE_HEADER,
27 BENCHMARK_REPEAT_INDEX_HEADER, BENCHMARK_REQUEST_INDEX_HEADER, BENCHMARK_RUN_ID_HEADER,
28};
29use ferrum_interfaces::engine::{EmbedEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine};
30use ferrum_types::{
31 has_unclosed_model_reasoning_block, model_reasoning_markers,
32 parse_harmony_response_for_finish_reason, parse_model_reasoning_response,
33 should_defer_model_reasoning_stream_delta, EngineMetrics, EngineStatus, FerrumConfigBuilder,
34 FerrumError as Error, FerrumProfileEvent, FinishReason, InferenceExecutionEvidence,
35 InferenceRequest, InferenceResponse, ModelId, ModelOutputProtocol, ParsedReasoningResponse,
36 Priority, ProcessMemoryObservation, ProcessMemorySample, ProcessMemorySampler,
37 ProfileEntrypoint, ProfileError, ProfileEventKind, ProfileStatus, ReplayReference, RequestId,
38 ResolvedFerrumConfig, ResourceAction, ResourceTraceEvent, ResponseCompletionBoundary,
39 RuntimeConfigSnapshot, SamplingParams, StructuredOutputStart, TokenId, TokenUsage,
40 DEFAULT_CHAT_REPETITION_PENALTY, DEFAULT_MAX_TOKENS_METADATA_KEY,
41 OBSERVABILITY_PROFILE_SCHEMA_VERSION, THINK_END_TAG, THINK_START_TAG,
42};
43use sha2::{Digest, Sha256};
44use std::{
45 collections::{BTreeMap, HashMap},
46 error::Error as StdError,
47 fs,
48 path::{Path, PathBuf},
49 sync::{
50 atomic::{AtomicBool, Ordering},
51 Arc, Mutex, OnceLock,
52 },
53 time::Instant,
54};
55use tokio::sync::{mpsc, Notify};
56use tokio_stream::StreamExt;
57use tower::ServiceBuilder;
58use tower_http::{cors::CorsLayer, trace::TraceLayer};
59use tracing::{debug, error, info, span, warn, Level};
60use uuid::Uuid;
61
62mod responses;
63
64const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
65const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
66const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 4096;
67const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
68const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
69const MAX_CACHED_JSON_SCHEMA_VALIDATORS: usize = 64;
70const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
71 &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
72const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
73static JSON_SCHEMA_VALIDATOR_CACHE: OnceLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
74 OnceLock::new();
75
76pub fn default_chat_sampling_params() -> SamplingParams {
80 SamplingParams {
81 max_tokens: DEFAULT_COMPLETION_MAX_TOKENS as usize,
82 temperature: DEFAULT_SAMPLING_TEMPERATURE,
83 top_p: DEFAULT_SAMPLING_TOP_P,
84 repetition_penalty: DEFAULT_CHAT_REPETITION_PENALTY,
85 ..SamplingParams::default()
86 }
87}
88
89#[derive(Debug, Clone)]
90struct CachePolicy {
91 prefix_cache_enabled: bool,
92 session_cache_mode: String,
93 session_cache_max_entries: usize,
94 session_cache_max_tokens: usize,
95}
96
97impl CachePolicy {
98 fn current() -> Self {
99 Self {
100 prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
101 .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
102 .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
103 .unwrap_or(false),
104 session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
105 .unwrap_or_else(|_| "off".to_string())
106 .to_ascii_lowercase(),
107 session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
108 session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
109 }
110 }
111
112 fn session_memory_enabled(&self) -> bool {
113 self.session_cache_mode == "memory"
114 }
115}
116
117fn env_bool(key: &str) -> Option<bool> {
118 match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
119 "1" | "true" | "yes" | "on" => Some(true),
120 "0" | "false" | "no" | "off" => Some(false),
121 _ => None,
122 }
123}
124
125fn env_usize(key: &str) -> Option<usize> {
126 std::env::var(key).ok()?.parse().ok()
127}
128
129static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
131 std::sync::OnceLock::new();
132
133pub fn init_prometheus_recorder() {
138 PROM_HANDLE.get_or_init(|| {
139 let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
140 let handle = builder
141 .install_recorder()
142 .expect("Failed to install Prometheus recorder");
143 info!("Prometheus metrics recorder installed");
144 handle
145 });
146}
147
148pub struct AxumServer {
154 state: AppState,
155 config: ServerConfig,
156 lifecycle: Arc<AxumServerLifecycle>,
157}
158
159#[derive(Default)]
160struct AxumServerLifecycle {
161 shutdown_requested: AtomicBool,
162 running: AtomicBool,
163 engines_stopped: AtomicBool,
164 shutdown_notify: Notify,
165 stopped_notify: Notify,
166 stop_lock: tokio::sync::Mutex<()>,
167}
168
169impl AxumServerLifecycle {
170 fn request_shutdown(&self) {
171 self.shutdown_requested.store(true, Ordering::Release);
172 self.shutdown_notify.notify_waiters();
173 }
174
175 async fn wait_for_shutdown(&self) {
176 while !self.shutdown_requested.load(Ordering::Acquire) {
177 self.shutdown_notify.notified().await;
178 }
179 }
180
181 async fn wait_until_stopped(&self) {
182 while self.running.load(Ordering::Acquire) {
183 self.stopped_notify.notified().await;
184 }
185 }
186}
187
188struct AxumServerRunGuard {
189 lifecycle: Arc<AxumServerLifecycle>,
190}
191
192impl Drop for AxumServerRunGuard {
193 fn drop(&mut self) {
194 self.lifecycle.running.store(false, Ordering::Release);
195 self.lifecycle.stopped_notify.notify_waiters();
196 }
197}
198
199fn single_model_registry(engine_model_id: ModelId, kind: ServedModelKind) -> ServedModelRegistry {
200 let public_name = engine_model_id.to_string();
201 ServedModelRegistry::try_new(engine_model_id, kind, vec![public_name], vec![])
202 .expect("engine config must contain a valid model id")
203}
204
205impl AxumServer {
206 pub fn from_state(state: AppState) -> Self {
208 Self {
209 state,
210 config: ServerConfig::default(),
211 lifecycle: Arc::new(AxumServerLifecycle::default()),
212 }
213 }
214
215 pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
217 Self::from_state(AppState::default().with_llm(engine))
218 }
219
220 pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
222 Self::from_state(AppState::default().with_embed(engine))
223 }
224
225 pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
228 Self::from_state(AppState::default().with_transcribe(engine))
229 }
230
231 pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
233 Self::from_state(AppState::default().with_tts(engine))
234 }
235
236 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
240 self.state = self.state.with_auto_config(auto_config);
241 self
242 }
243
244 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
247 self.state = self.state.with_prompt_template(prompt_template);
248 self
249 }
250
251 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
254 self.state = self.state.with_default_enable_thinking(enable_thinking);
255 self
256 }
257
258 pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
261 self.state = self.state.with_interleaved_system_coalescing(enabled);
262 self
263 }
264
265 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
269 self.state = self.state.with_served_model_registry(registry);
270 self
271 }
272
273 pub fn with_lora_adapters(
275 mut self,
276 base_model_id: impl Into<String>,
277 adapters: Vec<LoraAdapterModel>,
278 ) -> ferrum_types::Result<Self> {
279 let base_model_id = base_model_id.into();
280 let registry = if self.state.served_model_registry.is_empty() {
281 ServedModelRegistry::try_new(
282 base_model_id.clone(),
283 ServedModelKind::Llm,
284 vec![base_model_id],
285 adapters,
286 )
287 } else {
288 self.state
289 .served_model_registry
290 .try_with_lora_adapters(&base_model_id, adapters)
291 }
292 .map_err(|error| Error::config(error.to_string()))?;
293 self.state = self.state.with_served_model_registry(registry);
294 Ok(self)
295 }
296
297 async fn shutdown_loaded_engines(&self) -> ferrum_types::Result<()> {
298 let mut first_error = None;
299 if let Some(engine) = &self.state.llm {
300 if let Err(error) = engine.shutdown().await {
301 first_error = Some(error);
302 }
303 }
304 if let Some(engine) = &self.state.embed {
305 if let Err(error) = engine.shutdown().await {
306 if first_error.is_none() {
307 first_error = Some(error);
308 }
309 }
310 }
311 if let Some(engine) = &self.state.transcribe {
312 if let Err(error) = engine.shutdown().await {
313 if first_error.is_none() {
314 first_error = Some(error);
315 }
316 }
317 }
318 if let Some(engine) = &self.state.tts {
319 if let Err(error) = engine.shutdown().await {
320 if first_error.is_none() {
321 first_error = Some(error);
322 }
323 }
324 }
325 first_error.map_or(Ok(()), Err)
326 }
327
328 #[allow(dead_code)]
330 fn build_router(&self) -> Router {
331 self.build_router_with_state(self.state.clone())
332 }
333
334 fn build_router_with_state(&self, app_state: AppState) -> Router {
335 Router::new()
336 .route("/v1/chat/completions", post(chat_completions_handler))
338 .route("/v1/responses", post(responses::responses_handler))
339 .route("/v1/completions", post(completions_handler))
340 .route("/v1/embeddings", post(embeddings_handler))
341 .route("/v1/audio/transcriptions", post(transcriptions_handler))
342 .route("/v1/audio/speech", post(speech_handler))
343 .route("/v1/models", get(models_handler))
344 .route("/health", get(health_handler))
346 .route("/metrics", get(metrics_handler))
347 .route("/", get(root_handler))
348 .layer(
350 ServiceBuilder::new()
351 .layer(TraceLayer::new_for_http())
352 .layer(CorsLayer::permissive()), )
354 .with_state(app_state)
355 }
356}
357
358#[derive(Clone, Default)]
362pub struct AppState {
363 pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
364 pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
365 pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
366 pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
367 pub auto_config: Option<ResolvedFerrumConfig>,
368 pub prompt_template: Option<Arc<ModelChatTemplate>>,
369 pub default_enable_thinking: Option<bool>,
370 interleaved_system_coalescing: Option<bool>,
371 pub served_model_registry: Arc<ServedModelRegistry>,
372 pub request_dump_dir: Option<Arc<PathBuf>>,
373 pub profile_jsonl: Option<Arc<PathBuf>>,
374 pub profile_detail: ferrum_types::ObservabilityProfileDetail,
375 pub memory_profile_jsonl: Option<Arc<PathBuf>>,
376 pub first_request_memory_recorded: Arc<AtomicBool>,
377 cache: Arc<CacheRuntimeState>,
378}
379
380impl AppState {
381 pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
382 if self.served_model_registry.is_empty() {
383 self.served_model_registry = Arc::new(single_model_registry(
384 engine.config().model.model_id.clone(),
385 ServedModelKind::Llm,
386 ));
387 }
388 self.llm = Some(engine);
389 self
390 }
391 pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
392 if self.served_model_registry.is_empty() {
393 self.served_model_registry = Arc::new(single_model_registry(
394 engine.config().model.model_id.clone(),
395 ServedModelKind::Embedding,
396 ));
397 }
398 self.embed = Some(engine);
399 self
400 }
401 pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
402 if self.served_model_registry.is_empty() {
403 self.served_model_registry = Arc::new(single_model_registry(
404 engine.config().model.model_id.clone(),
405 ServedModelKind::Transcription,
406 ));
407 }
408 self.transcribe = Some(engine);
409 self
410 }
411 pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
412 if self.served_model_registry.is_empty() {
413 self.served_model_registry = Arc::new(single_model_registry(
414 engine.config().model.model_id.clone(),
415 ServedModelKind::Speech,
416 ));
417 }
418 self.tts = Some(engine);
419 self
420 }
421
422 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
423 self.auto_config = Some(auto_config);
424 self
425 }
426
427 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
428 self.prompt_template = prompt_template.map(Arc::new);
429 self
430 }
431
432 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
433 self.default_enable_thinking = enable_thinking;
434 self
435 }
436
437 pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
438 self.interleaved_system_coalescing = Some(enabled);
439 self
440 }
441
442 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
443 self.served_model_registry = Arc::new(registry);
444 self
445 }
446
447 pub fn with_request_dump_dir(mut self, request_dump_dir: Option<PathBuf>) -> Self {
448 self.request_dump_dir = request_dump_dir.map(Arc::new);
449 self
450 }
451
452 pub fn with_profile_jsonl(mut self, profile_jsonl: Option<PathBuf>) -> Self {
453 self.profile_jsonl = profile_jsonl.map(Arc::new);
454 self
455 }
456
457 pub fn with_profile_detail(
458 mut self,
459 profile_detail: ferrum_types::ObservabilityProfileDetail,
460 ) -> Self {
461 self.profile_detail = profile_detail;
462 self
463 }
464
465 pub fn with_memory_profile_jsonl(mut self, memory_profile_jsonl: Option<PathBuf>) -> Self {
466 self.memory_profile_jsonl = memory_profile_jsonl.map(Arc::new);
467 self
468 }
469
470 async fn status(&self) -> EngineStatus {
473 if let Some(e) = &self.llm {
474 return e.status().await;
475 }
476 if let Some(e) = &self.embed {
477 return e.status().await;
478 }
479 if let Some(e) = &self.transcribe {
480 return e.status().await;
481 }
482 if let Some(e) = &self.tts {
483 return e.status().await;
484 }
485 EngineStatus {
486 is_ready: false,
487 loaded_models: vec![],
488 active_requests: 0,
489 queued_requests: 0,
490 memory_usage: ferrum_types::MemoryUsage {
491 total_bytes: 0,
492 used_bytes: 0,
493 free_bytes: 0,
494 gpu_memory_bytes: None,
495 cpu_memory_bytes: None,
496 cache_memory_bytes: 0,
497 utilization_percent: 0.0,
498 },
499 uptime_seconds: 0,
500 last_heartbeat: chrono::Utc::now(),
501 version: env!("CARGO_PKG_VERSION").to_string(),
502 }
503 }
504
505 fn metrics(&self) -> EngineMetrics {
506 if let Some(e) = &self.llm {
507 return e.metrics();
508 }
509 if let Some(e) = &self.embed {
510 return e.metrics();
511 }
512 if let Some(e) = &self.transcribe {
513 return e.metrics();
514 }
515 if let Some(e) = &self.tts {
516 return e.metrics();
517 }
518 EngineMetrics {
519 total_requests: 0,
520 successful_requests: 0,
521 failed_requests: 0,
522 avg_request_latency_ms: 0.0,
523 p95_request_latency_ms: 0.0,
524 p99_request_latency_ms: 0.0,
525 throughput_rps: 0.0,
526 tokens_per_second: 0.0,
527 queue_metrics: Default::default(),
528 resource_utilization: Default::default(),
529 error_stats: Default::default(),
530 performance_breakdown: Default::default(),
531 }
532 }
533}
534
535#[derive(Default)]
536struct CacheRuntimeState {
537 stats: Mutex<CacheStats>,
538 prefix_prompts: Mutex<HashMap<String, usize>>,
539 sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
540}
541
542#[derive(Debug, Clone, Default)]
543struct CacheStats {
544 prefix_hits: u64,
545 prefix_misses: u64,
546 prefix_evictions: u64,
547 prefix_saved_prefill_tokens: u64,
548 prefix_entries: u64,
549 prefix_bytes: u64,
550 session_hits: u64,
551 session_misses: u64,
552 session_evictions: u64,
553 session_entries: u64,
554 session_tokens: u64,
555}
556
557#[derive(Clone)]
558struct SessionContext {
559 id: String,
560 prior_messages: Vec<ChatMessage>,
561 incoming_messages: Vec<ChatMessage>,
562}
563
564impl CacheRuntimeState {
565 fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
566 if !policy.prefix_cache_enabled {
567 return;
568 }
569
570 let prompt_tokens = approx_tokens(prompt);
571 let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
572 let saved_tokens = prompts
573 .keys()
574 .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
575 .max()
576 .unwrap_or(0);
577
578 let mut stats = self.stats.lock().expect("cache stats lock");
579 if saved_tokens > 0 {
580 stats.prefix_hits += 1;
581 stats.prefix_saved_prefill_tokens += saved_tokens as u64;
582 } else {
583 stats.prefix_misses += 1;
584 }
585
586 let max_entries = policy.session_cache_max_entries.max(1);
587 if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
588 if let Some(key) = prompts.keys().next().cloned() {
589 prompts.remove(&key);
590 stats.prefix_evictions += 1;
591 }
592 }
593 prompts.insert(prompt.to_string(), prompt_tokens);
594 stats.prefix_entries = prompts.len() as u64;
595 stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
596 }
597
598 fn prepare_session_request(
599 &self,
600 request: &mut ChatCompletionsRequest,
601 headers: &HeaderMap,
602 policy: &CachePolicy,
603 ) -> Option<SessionContext> {
604 let session_id = request_session_id(headers, request)?;
605 if !policy.session_memory_enabled() {
606 return None;
607 }
608
609 let incoming_messages = request.messages.clone();
610 let prior_messages = {
611 let sessions = self.sessions.lock().expect("session cache lock");
612 sessions.get(&session_id).cloned().unwrap_or_default()
613 };
614 {
615 let mut stats = self.stats.lock().expect("cache stats lock");
616 if prior_messages.is_empty() {
617 stats.session_misses += 1;
618 } else {
619 stats.session_hits += 1;
620 let mut merged = prior_messages.clone();
621 merged.extend(request.messages.clone());
622 request.messages = merged;
623 }
624 }
625
626 Some(SessionContext {
627 id: session_id,
628 prior_messages,
629 incoming_messages,
630 })
631 }
632
633 fn update_session(
634 &self,
635 context: Option<SessionContext>,
636 assistant_message: ChatMessage,
637 policy: &CachePolicy,
638 ) {
639 let Some(context) = context else {
640 return;
641 };
642 if !policy.session_memory_enabled() {
643 return;
644 }
645
646 let mut history = context.prior_messages;
647 history.extend(context.incoming_messages);
648 history.push(assistant_message);
649 trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
650
651 let mut sessions = self.sessions.lock().expect("session cache lock");
652 if !sessions.contains_key(&context.id)
653 && sessions.len() >= policy.session_cache_max_entries.max(1)
654 {
655 if let Some(evict_key) = sessions.keys().next().cloned() {
656 sessions.remove(&evict_key);
657 self.stats
658 .lock()
659 .expect("cache stats lock")
660 .session_evictions += 1;
661 }
662 }
663 sessions.insert(context.id, history);
664
665 let entries = sessions.len() as u64;
666 let tokens = sessions
667 .values()
668 .map(|messages| {
669 messages
670 .iter()
671 .map(|msg| approx_tokens(&msg.content))
672 .sum::<usize>()
673 })
674 .sum::<usize>() as u64;
675 let mut stats = self.stats.lock().expect("cache stats lock");
676 stats.session_entries = entries;
677 stats.session_tokens = tokens;
678 }
679
680 fn stats(&self) -> CacheStats {
681 let mut stats = self.stats.lock().expect("cache stats lock").clone();
682 stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
683 let sessions = self.sessions.lock().expect("session cache lock");
684 stats.session_entries = sessions.len() as u64;
685 stats.session_tokens = sessions
686 .values()
687 .map(|messages| {
688 messages
689 .iter()
690 .map(|msg| approx_tokens(&msg.content))
691 .sum::<usize>()
692 })
693 .sum::<usize>() as u64;
694 stats
695 }
696
697 fn health_json(
698 &self,
699 policy: &CachePolicy,
700 engine_prefix_cache: Option<&serde_json::Value>,
701 ) -> serde_json::Value {
702 let stats = self.stats();
703 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
704 let prefix_misses =
705 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
706 let prefix_evictions =
707 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
708 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
709 .unwrap_or(stats.prefix_saved_prefill_tokens);
710 let prefix_entries =
711 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
712 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
713 let mut prefix_cache = serde_json::json!({
714 "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
715 "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
716 "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
717 "entries": prefix_entries,
718 "hits": prefix_hits,
719 "misses": prefix_misses,
720 "evictions": prefix_evictions,
721 "saved_prefill_tokens": prefix_saved,
722 "bytes": prefix_bytes,
723 "block_size": engine_u64(engine_prefix_cache, "block_size"),
724 "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
725 });
726 if let (Some(engine), Some(prefix)) = (
727 engine_prefix_cache.and_then(|value| value.as_object()),
728 prefix_cache.as_object_mut(),
729 ) {
730 for (key, value) in engine {
731 prefix.entry(key.clone()).or_insert_with(|| value.clone());
732 }
733 }
734 serde_json::json!({
735 "prefix_cache": prefix_cache,
736 "session_cache": {
737 "mode": policy.session_cache_mode,
738 "entries": stats.session_entries,
739 "hits": stats.session_hits,
740 "misses": stats.session_misses,
741 "evictions": stats.session_evictions,
742 "tokens": stats.session_tokens,
743 "max_entries": policy.session_cache_max_entries,
744 "max_tokens": policy.session_cache_max_tokens,
745 }
746 })
747 }
748
749 fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
750 let stats = self.stats();
751 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
752 let prefix_misses =
753 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
754 let prefix_evictions =
755 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
756 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
757 .unwrap_or(stats.prefix_saved_prefill_tokens);
758 let prefix_entries =
759 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
760 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
761 format!(
762 concat!(
763 "ferrum_prefix_cache_hits_total {}\n",
764 "ferrum_prefix_cache_misses_total {}\n",
765 "ferrum_prefix_cache_evictions_total {}\n",
766 "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
767 "ferrum_prefix_cache_entries {}\n",
768 "ferrum_prefix_cache_bytes {}\n",
769 "ferrum_session_cache_hits_total {}\n",
770 "ferrum_session_cache_misses_total {}\n",
771 "ferrum_session_cache_evictions_total {}\n",
772 "ferrum_session_cache_entries {}\n",
773 "ferrum_session_cache_tokens {}\n"
774 ),
775 prefix_hits,
776 prefix_misses,
777 prefix_evictions,
778 prefix_saved,
779 prefix_entries,
780 prefix_bytes,
781 stats.session_hits,
782 stats.session_misses,
783 stats.session_evictions,
784 stats.session_entries,
785 stats.session_tokens,
786 )
787 }
788}
789
790fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
791 snapshot?.get(key)?.as_u64()
792}
793
794fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
795 snapshot?.get(key)?.as_bool()
796}
797
798fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
799 snapshot?.get(key)?.as_str()
800}
801
802fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
803 match auto_config {
804 Some(auto_config) => auto_config.effective_config_document(),
805 None => {
806 match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
807 Ok(auto_config) => auto_config.effective_config_document(),
808 Err(err) => serde_json::json!({
809 "schema_version": 1,
810 "error": err.to_string(),
811 }),
812 }
813 }
814 }
815}
816
817fn admission_health_json(
818 engine_status: &EngineStatus,
819 scheduler_metrics: &EngineMetrics,
820 auto_config: &serde_json::Value,
821 runtime_snapshot: Option<&ferrum_types::ExecutorAdmissionSnapshot>,
822 runtime_error: Option<&str>,
823) -> serde_json::Value {
824 let configured = auto_config
825 .get("admission")
826 .and_then(|value| value.as_object());
827 let preflight_effective_max_concurrent = configured
828 .and_then(|value| value.get("effective_max_concurrent"))
829 .and_then(|value| value.as_u64());
830 let effective_max_concurrent = if runtime_error.is_some() {
831 None
832 } else {
833 Some(
834 runtime_snapshot
835 .map(|snapshot| u64::from(snapshot.maximum_active_sequences()))
836 .or(preflight_effective_max_concurrent)
837 .unwrap_or_else(|| {
838 (engine_status.active_requests + engine_status.queued_requests)
839 .max(1)
840 .try_into()
841 .unwrap_or(u64::MAX)
842 }),
843 )
844 };
845 let active_sequences = runtime_error.is_none().then(|| {
846 runtime_snapshot
847 .map(|snapshot| u64::from(snapshot.active_sequences()))
848 .unwrap_or_else(|| engine_status.active_requests as u64)
849 });
850 let waiting_requests = runtime_error.is_none().then(|| {
851 runtime_snapshot
852 .map(|snapshot| u64::from(snapshot.waiting_requests()))
853 .unwrap_or_else(|| engine_status.queued_requests as u64)
854 });
855 serde_json::json!({
856 "schema_version": 2,
857 "source": if runtime_error.is_some() {
858 "runtime_error"
859 } else if runtime_snapshot.is_some() {
860 "runtime_executor"
861 } else {
862 "startup_preflight_and_engine_status"
863 },
864 "runtime_snapshot_available": runtime_snapshot.is_some(),
865 "runtime_contract_error": runtime_error,
866 "resource_authority": runtime_snapshot
867 .and_then(|snapshot| serde_json::to_value(snapshot.resource_authority()).ok())
868 .unwrap_or(serde_json::Value::Null),
869 "effective_max_concurrent": effective_max_concurrent,
870 "maximum_active_sequences": runtime_snapshot
871 .map(|snapshot| u64::from(snapshot.maximum_active_sequences())),
872 "maximum_scheduled_tokens": runtime_snapshot
873 .map(|snapshot| snapshot.maximum_scheduled_tokens()),
874 "preflight_effective_max_concurrent": preflight_effective_max_concurrent,
875 "queue_depth": waiting_requests,
876 "active_sequences": active_sequences,
877 "active_prefill": runtime_snapshot
878 .map(|snapshot| u64::from(snapshot.active_prefill_sequences())),
879 "active_decode": runtime_snapshot
880 .map(|snapshot| u64::from(snapshot.active_decode_sequences())),
881 "current_batch_size": runtime_snapshot
882 .and_then(|snapshot| snapshot.current_batch_size())
883 .map(u64::from),
884 "capacity_blocked_requests": runtime_snapshot
885 .and_then(|snapshot| snapshot.capacity_blocked_requests())
886 .map(u64::from),
887 "rejected_requests_total": 0u64,
888 "failed_requests_total": scheduler_metrics.failed_requests,
889 "completed_requests_total": scheduler_metrics.successful_requests,
890 "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
891 "scheduler_policy": configured
892 .and_then(|value| value.get("scheduler_policy"))
893 .and_then(|value| value.as_str())
894 .unwrap_or("unknown"),
895 "phase_detail_source": if runtime_snapshot.is_some() {
896 "scheduler_request_index_single_read"
897 } else {
898 "unavailable"
899 },
900 })
901}
902
903fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
904 let snapshot_available = u8::from(
905 admission
906 .get("runtime_snapshot_available")
907 .and_then(serde_json::Value::as_bool)
908 .unwrap_or(false),
909 );
910 let mut output = format!("ferrum_admission_runtime_snapshot_available {snapshot_available}\n");
911 for (field, metric) in [
912 (
913 "effective_max_concurrent",
914 "ferrum_admission_effective_max_concurrent",
915 ),
916 (
917 "maximum_active_sequences",
918 "ferrum_admission_maximum_active_sequences",
919 ),
920 (
921 "maximum_scheduled_tokens",
922 "ferrum_admission_maximum_scheduled_tokens",
923 ),
924 ("queue_depth", "ferrum_admission_queue_depth"),
925 (
926 "capacity_blocked_requests",
927 "ferrum_admission_capacity_blocked_requests",
928 ),
929 ("active_sequences", "ferrum_admission_active_sequences"),
930 ("active_prefill", "ferrum_admission_active_prefill"),
931 ("active_decode", "ferrum_admission_active_decode"),
932 ("current_batch_size", "ferrum_admission_current_batch_size"),
933 (
934 "rejected_requests_total",
935 "ferrum_admission_rejected_requests_total",
936 ),
937 (
938 "failed_requests_total",
939 "ferrum_admission_failed_requests_total",
940 ),
941 (
942 "completed_requests_total",
943 "ferrum_admission_completed_requests_total",
944 ),
945 ] {
946 if let Some(value) = admission.get(field).and_then(serde_json::Value::as_u64) {
947 output.push_str(&format!("{metric} {value}\n"));
948 }
949 }
950 output
951}
952
953fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
954 headers
955 .get(FERRUM_SESSION_HEADER)
956 .and_then(|value| value.to_str().ok())
957 .map(str::trim)
958 .filter(|value| !value.is_empty())
959 .map(str::to_string)
960 .or_else(|| {
961 request
962 .metadata
963 .as_ref()
964 .and_then(|metadata| metadata.get("ferrum_session_id"))
965 .and_then(|value| value.as_str())
966 .map(str::trim)
967 .filter(|value| !value.is_empty())
968 .map(str::to_string)
969 })
970}
971
972fn benchmark_request_correlation(
973 headers: &HeaderMap,
974) -> std::result::Result<Option<BenchmarkRequestCorrelation>, ServerError> {
975 let header_value = |name: &'static str| {
976 headers
977 .get(name)
978 .map(|value| {
979 value.to_str().map_err(|_| {
980 ServerError::invalid_request(
981 format!("{name} must contain visible ASCII text"),
982 Some(name),
983 )
984 })
985 })
986 .transpose()
987 };
988 BenchmarkRequestCorrelation::from_header_values(
989 header_value(BENCHMARK_RUN_ID_HEADER)?,
990 header_value(BENCHMARK_CELL_ID_HEADER)?,
991 header_value(BENCHMARK_REPEAT_INDEX_HEADER)?,
992 header_value(BENCHMARK_PHASE_HEADER)?,
993 header_value(BENCHMARK_REQUEST_INDEX_HEADER)?,
994 )
995 .map_err(|error| ServerError::invalid_request(error, Some(BENCHMARK_RUN_ID_HEADER)))
996}
997
998fn extend_benchmark_profile_attributes(
999 attributes: &mut BTreeMap<String, serde_json::Value>,
1000 correlation: Option<&BenchmarkRequestCorrelation>,
1001) {
1002 let Some(correlation) = correlation else {
1003 return;
1004 };
1005 attributes.extend([
1006 (
1007 "benchmark_run_id".to_string(),
1008 serde_json::json!(correlation.benchmark_run_id),
1009 ),
1010 (
1011 "cell_id".to_string(),
1012 serde_json::json!(correlation.cell_id),
1013 ),
1014 (
1015 "repeat_index".to_string(),
1016 serde_json::json!(correlation.repeat_index),
1017 ),
1018 (
1019 "phase".to_string(),
1020 serde_json::json!(correlation.phase.as_str()),
1021 ),
1022 (
1023 "request_index".to_string(),
1024 serde_json::json!(correlation.request_index),
1025 ),
1026 ]);
1027}
1028
1029fn approx_tokens(text: &str) -> usize {
1030 approx_tokens_for_chars(text.chars().count())
1031}
1032
1033fn approx_tokens_for_chars(chars: usize) -> usize {
1034 (chars / 4).max(1)
1035}
1036
1037fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
1038 a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
1039}
1040
1041fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
1042 let max_tokens = max_tokens.max(1);
1043 while messages.len() > 1
1044 && messages
1045 .iter()
1046 .map(|msg| approx_tokens(&msg.content))
1047 .sum::<usize>()
1048 > max_tokens
1049 {
1050 messages.remove(0);
1051 }
1052}
1053
1054#[async_trait]
1055impl HttpServer for AxumServer {
1056 async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
1057 if self.lifecycle.shutdown_requested.load(Ordering::Acquire) {
1058 return Err(Error::internal(
1059 "cannot start Axum server after shutdown was requested",
1060 ));
1061 }
1062 self.lifecycle
1063 .running
1064 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1065 .map_err(|_| Error::internal("Axum server is already running"))?;
1066 let _run_guard = AxumServerRunGuard {
1067 lifecycle: Arc::clone(&self.lifecycle),
1068 };
1069 let addr = format!("{}:{}", config.host, config.port);
1070 info!("Starting Axum server on {}", addr);
1071
1072 let app = self.build_router_with_state(
1073 self.state
1074 .clone()
1075 .with_request_dump_dir(config.request_dump_dir.clone())
1076 .with_profile_jsonl(config.profile_jsonl.clone())
1077 .with_profile_detail(config.profile_detail)
1078 .with_memory_profile_jsonl(config.memory_profile_jsonl.clone()),
1079 );
1080 let listener = tokio::net::TcpListener::bind(&addr)
1081 .await
1082 .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
1083
1084 info!("Server listening on {}", addr);
1085
1086 let lifecycle = Arc::clone(&self.lifecycle);
1087 axum::serve(listener, app)
1088 .with_graceful_shutdown(async move { lifecycle.wait_for_shutdown().await })
1089 .await
1090 .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
1091
1092 Ok(())
1093 }
1094
1095 async fn stop(&self, timeout: std::time::Duration) -> ferrum_types::Result<()> {
1096 let _stop_guard = self.lifecycle.stop_lock.lock().await;
1097 info!("Stopping Axum server");
1098 self.lifecycle.request_shutdown();
1099
1100 let mut first_error = None;
1101 if self.lifecycle.running.load(Ordering::Acquire) {
1102 if tokio::time::timeout(timeout, self.lifecycle.wait_until_stopped())
1103 .await
1104 .is_err()
1105 {
1106 first_error = Some(Error::internal(format!(
1107 "Axum server did not drain within {} ms",
1108 timeout.as_millis()
1109 )));
1110 }
1111 }
1112
1113 if !self.lifecycle.engines_stopped.load(Ordering::Acquire) {
1114 match tokio::time::timeout(timeout, self.shutdown_loaded_engines()).await {
1115 Ok(Ok(())) => {
1116 self.lifecycle
1117 .engines_stopped
1118 .store(true, Ordering::Release);
1119 }
1120 Ok(Err(error)) => {
1121 if first_error.is_none() {
1122 first_error = Some(error);
1123 }
1124 }
1125 Err(_) => {
1126 if first_error.is_none() {
1127 first_error = Some(Error::internal(format!(
1128 "engine shutdown did not complete within {} ms",
1129 timeout.as_millis()
1130 )));
1131 }
1132 }
1133 }
1134 }
1135
1136 first_error.map_or(Ok(()), Err)
1137 }
1138
1139 fn is_running(&self) -> bool {
1140 self.lifecycle.running.load(Ordering::Acquire)
1141 }
1142
1143 fn address(&self) -> Option<std::net::SocketAddr> {
1144 format!("{}:{}", self.config.host, self.config.port)
1146 .parse()
1147 .ok()
1148 }
1149
1150 fn register_handler(
1151 &mut self,
1152 _path: &str,
1153 _method: HttpMethod,
1154 _handler: Box<dyn crate::traits::RequestHandler>,
1155 ) {
1156 unimplemented!("Dynamic handler registration not implemented in MVP")
1158 }
1159
1160 fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
1161 unimplemented!("Dynamic middleware registration not implemented in MVP")
1163 }
1164
1165 fn get_metrics(&self) -> ServerMetrics {
1166 ServerMetrics {
1168 total_requests: 0,
1169 requests_by_endpoint: std::collections::HashMap::new(),
1170 requests_by_status: std::collections::HashMap::new(),
1171 avg_response_time_ms: 0.0,
1172 p95_response_time_ms: 0.0,
1173 p99_response_time_ms: 0.0,
1174 active_connections: 0,
1175 bytes_sent: 0,
1176 bytes_received: 0,
1177 error_rate: 0.0,
1178 uptime_seconds: 0,
1179 }
1180 }
1181
1182 async fn health_check(&self) -> HealthStatus {
1183 HealthStatus::Healthy
1184 }
1185}
1186
1187async fn chat_completions_handler(
1189 State(state): State<AppState>,
1190 headers: HeaderMap,
1191 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1192) -> std::result::Result<Response, ServerError> {
1193 chat_completions_handler_with_phases(State(state), headers, request, None).await
1194}
1195
1196async fn chat_completions_handler_with_phases(
1197 State(state): State<AppState>,
1198 headers: HeaderMap,
1199 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1200 mut message_phases: Option<Vec<Option<AssistantMessagePhase>>>,
1201) -> std::result::Result<Response, ServerError> {
1202 let Json(mut request) = request.map_err(|error| {
1203 ServerError::invalid_request(
1204 format!(
1205 "invalid chat completions request: {}",
1206 json_rejection_detail(&error)
1207 ),
1208 None,
1209 )
1210 })?;
1211 let benchmark_correlation = benchmark_request_correlation(&headers)?;
1212 let cache_policy = CachePolicy::current();
1213 if message_phases
1214 .as_ref()
1215 .is_some_and(|phases| phases.len() != request.messages.len())
1216 {
1217 return Err(ServerError::InternalError(
1218 "Responses message phase metadata did not match input history".to_string(),
1219 ));
1220 }
1221 let session_context =
1222 state
1223 .cache
1224 .prepare_session_request(&mut request, &headers, &cache_policy);
1225 if let Some(phases) = &mut message_phases {
1226 let prepended = request
1227 .messages
1228 .len()
1229 .checked_sub(phases.len())
1230 .ok_or_else(|| {
1231 ServerError::InternalError(
1232 "session preparation shortened Responses input history".to_string(),
1233 )
1234 })?;
1235 phases.splice(0..0, std::iter::repeat(None).take(prepended));
1236 }
1237
1238 let span = span!(Level::INFO, "chat_completions", model = %request.model);
1239 let _enter = span.enter();
1240
1241 info!(
1242 "Received chat completions request for model: {}",
1243 request.model
1244 );
1245 debug!("Request: {:?}", request);
1246
1247 validate_chat_request(&request)?;
1250 let (engine_model_id, lora_adapter) = resolve_request_model(
1251 &state.served_model_registry,
1252 &request.model,
1253 ServedModelKind::Llm,
1254 )?;
1255
1256 let mut inference_request = convert_chat_request_with_template_model_and_default(
1258 &request,
1259 &engine_model_id.0,
1260 state.prompt_template.as_deref(),
1261 state.default_enable_thinking,
1262 state.interleaved_system_coalescing.unwrap_or(true),
1263 message_phases.as_deref(),
1264 )
1265 .map_err(server_error_from_ferrum_error)?;
1266 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
1267 if state.request_dump_dir.is_some() {
1268 inference_request.evidence_request.capture_prompt_token_ids = true;
1269 }
1270 inference_request
1271 .evidence_request
1272 .capture_engine_token_timing = state.profile_detail.captures_engine_token_timing();
1273 state
1274 .cache
1275 .record_prefix_prompt(&inference_request.prompt, &cache_policy);
1276 if let Err(err) =
1277 write_chat_request_replay_bundle(&state, &headers, &request, &inference_request)
1278 {
1279 warn!("failed to write chat request replay bundle: {}", err);
1280 }
1281
1282 if request.stream.unwrap_or(false) {
1284 handle_chat_completions_stream(state, request, inference_request, benchmark_correlation)
1285 .await
1286 } else {
1287 handle_chat_completions_sync(
1288 state,
1289 request,
1290 inference_request,
1291 session_context,
1292 benchmark_correlation,
1293 )
1294 .await
1295 }
1296}
1297
1298fn json_rejection_detail(rejection: &JsonRejection) -> String {
1299 const MAX_DETAIL_CHARS: usize = 512;
1300
1301 let mut details = Vec::new();
1302 let mut current: Option<&(dyn StdError + 'static)> = Some(rejection);
1303 while let Some(error) = current {
1304 let detail = error.to_string();
1305 if !detail.is_empty() && details.last() != Some(&detail) {
1306 details.push(detail);
1307 }
1308 current = error.source();
1309 }
1310
1311 details.join(": ").chars().take(MAX_DETAIL_CHARS).collect()
1312}
1313
1314fn write_chat_request_replay_bundle(
1315 state: &AppState,
1316 headers: &HeaderMap,
1317 openai_request: &ChatCompletionsRequest,
1318 inference_request: &InferenceRequest,
1319) -> std::result::Result<(), String> {
1320 let Some(root) = state.request_dump_dir.as_ref() else {
1321 return Ok(());
1322 };
1323 let request_id = inference_request.id.to_string();
1324 let bundle_dir = root.join(&request_id);
1325 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1326
1327 let sanitized_body = sanitized_chat_request_body(openai_request);
1328 let replay_body_path = bundle_dir.join("replay_body.json");
1329 write_json_value(&replay_body_path, &sanitized_body)?;
1330 let engine_replay_argv = replay_bundle_argv(&bundle_dir);
1331 let output_text_body = format!(
1332 "[server request replay emitted before response]\nsha256={}\nchars=0\n",
1333 sha256_hex(b"")
1334 );
1335
1336 let request = serde_json::json!({
1337 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1338 "entrypoint": "serve",
1339 "request_id": request_id,
1340 "model": openai_request.model.clone(),
1341 "backend": "actual",
1342 "endpoint": "/v1/chat/completions",
1343 "method": "POST",
1344 "stream": openai_request.stream.unwrap_or(false),
1345 "actual_model_smoke": true,
1346 "sanitized": true,
1347 "http": {
1348 "method": "POST",
1349 "path": "/v1/chat/completions",
1350 "headers": sanitized_replay_headers(headers),
1351 "body": sanitized_body
1352 }
1353 });
1354 let files = [
1355 ("request.json", request),
1356 (
1357 "prompt_token_ids.json",
1358 serde_json::json!({
1359 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1360 "request_id": request_id,
1361 "model": openai_request.model.clone(),
1362 "tokenizer_or_model": openai_request.model.clone(),
1363 "token_ids": null,
1364 "token_count": null,
1365 "unavailable_reason": "server request replay captures the OpenAI body before prompt token ids are retained",
1366 "sanitized": true
1367 }),
1368 ),
1369 (
1370 "sampling_params.json",
1371 serde_json::json!({
1372 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1373 "request_id": request_id,
1374 "sampling_params": inference_request.sampling_params.clone(),
1375 "unavailable_reason": null
1376 }),
1377 ),
1378 (
1379 "runtime_effective_config.json",
1380 serde_json::json!({
1381 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1382 "request_id": request_id,
1383 "entrypoint": "serve",
1384 "endpoint": "/v1/chat/completions",
1385 "stream": openai_request.stream.unwrap_or(false),
1386 "request_dump_dir": root.to_string_lossy(),
1387 "sanitized": true
1388 }),
1389 ),
1390 (
1391 "backend_selection.json",
1392 serde_json::json!({
1393 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1394 "request_id": request_id,
1395 "backend": "actual",
1396 "model": openai_request.model.clone(),
1397 "actual_model_smoke": true
1398 }),
1399 ),
1400 (
1401 "output_token_ids.json",
1402 serde_json::json!({
1403 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1404 "request_id": request_id,
1405 "token_ids": [],
1406 "token_count": 0,
1407 "finish_reason": null,
1408 "unavailable_reason": "server request replay bundle is emitted at request admission in this WP9 slice"
1409 }),
1410 ),
1411 (
1412 "bad_output_scan.json",
1413 serde_json::json!({
1414 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1415 "request_id": request_id,
1416 "bad_output": false,
1417 "bad_text_count": 0,
1418 "reasons": [],
1419 "first_bad_text_span": null,
1420 "failure_kind": null,
1421 "output_chars": 0,
1422 "classified_output_sha256": sha256_hex(b""),
1423 "output_sha256": sha256_hex(output_text_body.as_bytes())
1424 }),
1425 ),
1426 (
1427 "replay.command.json",
1428 serde_json::json!({
1429 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1430 "request_id": request_id,
1431 "entrypoint": "serve",
1432 "command": replay_curl_command(&bundle_dir),
1433 "argv": replay_curl_argv(&bundle_dir),
1434 "bundle_dir": bundle_dir.to_string_lossy(),
1435 "requires_running_server": true,
1436 "engine_replay": {
1437 "mode": "bundle_offline",
1438 "requires_http_server": false,
1439 "command": shell_command(&engine_replay_argv),
1440 "argv": engine_replay_argv
1441 },
1442 "sanitized": true
1443 }),
1444 ),
1445 ];
1446 for (name, value) in files {
1447 write_json_value(&bundle_dir.join(name), &value)?;
1448 }
1449 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1450 .map_err(|err| err.to_string())?;
1451 Ok(())
1452}
1453
1454fn write_chat_request_failure_diagnostics(
1455 state: &AppState,
1456 request_id: &str,
1457 failure_kind: &str,
1458 phase: &str,
1459 error_kind: &str,
1460 message: &str,
1461 engine_status: Option<&EngineStatus>,
1462) -> std::result::Result<(), String> {
1463 let admission_summary = state
1464 .auto_config
1465 .as_ref()
1466 .map(|config| config.admission_summary_document());
1467 write_chat_request_failure_diagnostics_at_root(
1468 state.request_dump_dir.as_ref().map(|root| root.as_path()),
1469 admission_summary.as_ref(),
1470 engine_status,
1471 request_id,
1472 failure_kind,
1473 phase,
1474 error_kind,
1475 message,
1476 )
1477}
1478
1479fn write_chat_request_completion_replay_bundle(
1480 request_dump_dir: Option<&Path>,
1481 request_id: &str,
1482 output_text: &str,
1483 output_token_ids: &[TokenId],
1484 finish_reason: Option<&str>,
1485) -> std::result::Result<(), String> {
1486 let Some(root) = request_dump_dir else {
1487 return Ok(());
1488 };
1489 let bundle_dir = root.join(request_id);
1490 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1491 let token_ids = output_token_ids
1492 .iter()
1493 .map(|token| token.get())
1494 .collect::<Vec<_>>();
1495 let output_text_body = format!(
1496 "[redacted actual output]\nsha256={}\nchars={}\n",
1497 sha256_hex(output_text.as_bytes()),
1498 output_text.chars().count()
1499 );
1500 write_json_value(
1501 &bundle_dir.join("output_token_ids.json"),
1502 &serde_json::json!({
1503 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1504 "request_id": request_id,
1505 "token_ids": token_ids,
1506 "token_count": output_token_ids.len(),
1507 "finish_reason": finish_reason,
1508 "unavailable_reason": null
1509 }),
1510 )?;
1511 write_json_value(
1512 &bundle_dir.join("bad_output_scan.json"),
1513 &bad_output_scan_json(request_id, output_text, None, output_text_body.as_bytes()),
1514 )?;
1515 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1516 .map_err(|err| err.to_string())?;
1517 Ok(())
1518}
1519
1520fn write_chat_prompt_token_evidence(
1521 request_dump_dir: Option<&Path>,
1522 request_id: &str,
1523 model: &str,
1524 execution_evidence: Option<&InferenceExecutionEvidence>,
1525) -> std::result::Result<(), String> {
1526 let (Some(root), Some(evidence)) = (request_dump_dir, execution_evidence) else {
1527 return Ok(());
1528 };
1529 let bundle_dir = root.join(request_id);
1530 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1531 let prompt_token_ids = evidence
1532 .prompt_token_ids
1533 .iter()
1534 .map(|token| token.get())
1535 .collect::<Vec<_>>();
1536 write_json_value(
1537 &bundle_dir.join("prompt_token_ids.json"),
1538 &serde_json::json!({
1539 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1540 "request_id": request_id,
1541 "model": model,
1542 "tokenizer_or_model": model,
1543 "token_ids": prompt_token_ids,
1544 "token_count": evidence.prompt_token_ids.len(),
1545 "unavailable_reason": null,
1546 "sanitized": true
1547 }),
1548 )
1549}
1550
1551#[derive(Clone, Copy, Default)]
1552struct ChatRequestProfileTiming<'a> {
1553 engine_evidence: Option<&'a InferenceExecutionEvidence>,
1554 first_engine_chunk_received_us: Option<u64>,
1555 first_sse_enqueue_us: Option<u64>,
1556}
1557
1558#[allow(clippy::too_many_arguments)]
1559fn write_chat_request_profile_event(
1560 state: &AppState,
1561 request_id: &str,
1562 benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1563 model: &str,
1564 stream: bool,
1565 phase: &str,
1566 started_at: Instant,
1567 timing: ChatRequestProfileTiming<'_>,
1568 output_token_count: usize,
1569 usage: Option<&TokenUsage>,
1570 finish_reason: Option<&str>,
1571 error: Option<ProfileError>,
1572) -> std::result::Result<(), String> {
1573 let Some(path) = state.profile_jsonl.as_ref() else {
1574 return Ok(());
1575 };
1576 let timestamp = chrono::Utc::now();
1577 let status = if error.is_some() {
1578 ProfileStatus::Failure
1579 } else {
1580 ProfileStatus::Ok
1581 };
1582 let duration_us = elapsed_us_since(started_at);
1583 let mut attributes = BTreeMap::from([
1584 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1585 (
1586 "diagnostic_only".to_string(),
1587 serde_json::json!(state.profile_detail.diagnostic_only()),
1588 ),
1589 (
1590 "endpoint".to_string(),
1591 serde_json::json!("/v1/chat/completions"),
1592 ),
1593 (
1594 "e2e_duration_us".to_string(),
1595 serde_json::json!(duration_us),
1596 ),
1597 ("l0_only".to_string(), serde_json::json!(false)),
1598 (
1599 "profile_detail".to_string(),
1600 serde_json::json!(state.profile_detail.as_str()),
1601 ),
1602 ("stream".to_string(), serde_json::json!(stream)),
1603 (
1604 "output_token_count".to_string(),
1605 serde_json::json!(output_token_count),
1606 ),
1607 (
1608 "execution_request_id".to_string(),
1609 serde_json::json!(format!("request.product.{request_id}")),
1610 ),
1611 ]);
1612 extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1613 if let Some(usage) = usage {
1614 attributes.insert(
1615 "prompt_token_count".to_string(),
1616 serde_json::json!(usage.prompt_tokens),
1617 );
1618 attributes.insert(
1619 "completion_token_count".to_string(),
1620 serde_json::json!(usage.completion_tokens),
1621 );
1622 attributes.insert(
1623 "total_token_count".to_string(),
1624 serde_json::json!(usage.total_tokens),
1625 );
1626 attributes.insert("token_count_source".to_string(), serde_json::json!("usage"));
1627 } else {
1628 attributes.insert(
1629 "completion_token_count".to_string(),
1630 serde_json::json!(output_token_count),
1631 );
1632 attributes.insert(
1633 "total_token_count".to_string(),
1634 serde_json::json!(output_token_count),
1635 );
1636 attributes.insert(
1637 "token_count_source".to_string(),
1638 serde_json::json!("generated_tokens"),
1639 );
1640 }
1641 if let Some(engine_timing) = timing
1642 .engine_evidence
1643 .and_then(|evidence| evidence.engine_token_timing.as_ref())
1644 {
1645 engine_timing
1646 .validate(output_token_count)
1647 .map_err(|error| format!("invalid engine token timing evidence: {error}"))?;
1648 attributes.extend(ferrum_types::engine_token_timing_profile_attributes(
1649 engine_timing,
1650 ));
1651 } else if status == ProfileStatus::Ok && state.profile_detail.captures_engine_token_timing() {
1652 return Err(format!(
1653 "{} profile completed without required engine token timing evidence",
1654 state.profile_detail.as_str()
1655 ));
1656 }
1657 if let Some(received_us) = timing.first_engine_chunk_received_us {
1658 attributes.insert(
1659 "engine_stream_first_chunk_received_us".to_string(),
1660 serde_json::json!(received_us),
1661 );
1662 }
1663 if let Some(enqueue_us) = timing.first_sse_enqueue_us {
1664 attributes.insert(
1665 "http_first_sse_enqueue_us".to_string(),
1666 serde_json::json!(enqueue_us),
1667 );
1668 }
1669 if stream {
1670 attributes.insert(
1671 "http_stream_flush_unavailable_reason".to_string(),
1672 serde_json::json!(
1673 "socket flush completion is outside the axum handler observation boundary"
1674 ),
1675 );
1676 }
1677 if let Some(reason) = finish_reason {
1678 attributes.insert("finish_reason".to_string(), serde_json::json!(reason));
1679 }
1680 if let Some(error) = error.as_ref() {
1681 attributes.insert(
1682 if error.blocking {
1683 "first_failure_event"
1684 } else {
1685 "terminal_failure_event"
1686 }
1687 .to_string(),
1688 serde_json::json!(true),
1689 );
1690 }
1691
1692 let replay = state.request_dump_dir.as_ref().map(|root| {
1693 let bundle_dir = root.join(request_id);
1694 ReplayReference {
1695 command: replay_curl_command(&bundle_dir),
1696 bundle_dir: Some(root.to_string_lossy().to_string()),
1697 }
1698 });
1699 let resource = error.as_ref().map(|error| ResourceTraceEvent {
1700 owner_kind: "request".to_string(),
1701 owner_id: request_id.to_string(),
1702 resource_kind: "chat_request".to_string(),
1703 action: ResourceAction::Reject,
1704 amount: None,
1705 before: None,
1706 after: None,
1707 capacity: Some(1),
1708 underflow_amount: None,
1709 reason: Some(error.message.clone()),
1710 error_kind: Some(error.kind.clone()),
1711 message: Some(error.message.clone()),
1712 resource_error_kind: Some(error.kind.clone()),
1713 });
1714 let event = FerrumProfileEvent {
1715 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1716 ts_unix_nanos: timestamp
1717 .timestamp_nanos_opt()
1718 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1719 event_id: format!(
1720 "evt-server-chat-{}-{request_id}",
1721 if stream { "stream" } else { "sync" }
1722 ),
1723 request_id: request_id.to_string(),
1724 correlation_id: Some(request_id.to_string()),
1725 entrypoint: ProfileEntrypoint::Serve,
1726 backend: "actual".to_string(),
1727 runtime_preset_hash: state
1728 .auto_config
1729 .as_ref()
1730 .map(ResolvedFerrumConfig::runtime_env_hash)
1731 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1732 phase: phase.to_string(),
1733 event_kind: ProfileEventKind::TimedSpan,
1734 timestamp,
1735 status,
1736 model: Some(model.to_string()),
1737 duration_us: Some(duration_us),
1738 memory: None,
1739 resource,
1740 error,
1741 replay,
1742 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1743 backend_detail: None,
1744 attributes,
1745 };
1746 append_profile_event(path.as_path(), &event)
1747}
1748
1749fn maybe_write_first_request_memory_stage(
1750 state: &AppState,
1751 request_id: &str,
1752 benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1753 model: &str,
1754 stream: bool,
1755 started_at: Instant,
1756 before: Option<ProcessMemorySample>,
1757) -> std::result::Result<(), String> {
1758 if state.profile_jsonl.is_none() && state.memory_profile_jsonl.is_none() {
1759 return Ok(());
1760 }
1761 if state
1762 .first_request_memory_recorded
1763 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1764 .is_err()
1765 {
1766 return Ok(());
1767 }
1768 let after = ProcessMemorySampler.sample();
1769 let memory = after.map(|after| ProcessMemoryObservation::from_samples(before, after));
1770 let timestamp = chrono::Utc::now();
1771 let mut attributes = BTreeMap::from([
1772 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1773 (
1774 "diagnostic_only".to_string(),
1775 serde_json::json!(state.profile_detail.diagnostic_only()),
1776 ),
1777 (
1778 "endpoint".to_string(),
1779 serde_json::json!("/v1/chat/completions"),
1780 ),
1781 ("l0_only".to_string(), serde_json::json!(false)),
1782 (
1783 "memory_stage".to_string(),
1784 serde_json::json!("first_request_done"),
1785 ),
1786 (
1787 "profile_detail".to_string(),
1788 serde_json::json!(state.profile_detail.as_str()),
1789 ),
1790 ("stream".to_string(), serde_json::json!(stream)),
1791 ]);
1792 extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1793 let memory_snapshot = if let Some(memory) = &memory {
1794 attributes.insert(
1795 "memory_measurement".to_string(),
1796 serde_json::json!("process_rss"),
1797 );
1798 attributes.insert(
1799 "process_memory_source".to_string(),
1800 serde_json::json!(memory.source),
1801 );
1802 memory.to_snapshot("process", Some("actual"))
1803 } else {
1804 attributes.insert(
1805 "memory_measurement".to_string(),
1806 serde_json::json!("not_collected"),
1807 );
1808 ferrum_types::MemorySnapshot {
1809 scope: "process".to_string(),
1810 backend: Some("actual".to_string()),
1811 before_bytes: Some(0),
1812 after_bytes: Some(0),
1813 current_bytes: Some(0),
1814 high_water_bytes: Some(0),
1815 available_bytes: None,
1816 }
1817 };
1818 let replay = state.request_dump_dir.as_ref().map(|root| {
1819 let bundle_dir = root.join(request_id);
1820 ReplayReference {
1821 command: replay_curl_command(&bundle_dir),
1822 bundle_dir: Some(root.to_string_lossy().to_string()),
1823 }
1824 });
1825 let event = FerrumProfileEvent {
1826 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1827 ts_unix_nanos: timestamp
1828 .timestamp_nanos_opt()
1829 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1830 event_id: format!("evt-server-chat-memory-first-request-{request_id}"),
1831 request_id: request_id.to_string(),
1832 correlation_id: Some(request_id.to_string()),
1833 entrypoint: ProfileEntrypoint::Serve,
1834 backend: "actual".to_string(),
1835 runtime_preset_hash: state
1836 .auto_config
1837 .as_ref()
1838 .map(ResolvedFerrumConfig::runtime_env_hash)
1839 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1840 phase: "actual_serve_first_request_done".to_string(),
1841 event_kind: ProfileEventKind::Memory,
1842 timestamp,
1843 status: ProfileStatus::Ok,
1844 model: Some(model.to_string()),
1845 duration_us: Some(elapsed_us_since(started_at)),
1846 memory: Some(memory_snapshot),
1847 resource: None,
1848 error: None,
1849 replay,
1850 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1851 backend_detail: None,
1852 attributes,
1853 };
1854 if let Some(path) = &state.profile_jsonl {
1855 append_profile_event(path.as_path(), &event)?;
1856 }
1857 if let Some(path) = &state.memory_profile_jsonl {
1858 append_profile_event(path.as_path(), &event)?;
1859 }
1860 Ok(())
1861}
1862
1863fn request_memory_sample_before(state: &AppState) -> Option<ProcessMemorySample> {
1864 (state.profile_jsonl.is_some() || state.memory_profile_jsonl.is_some())
1865 .then(|| ProcessMemorySampler.sample())
1866 .flatten()
1867}
1868
1869fn append_profile_event(
1870 path: &Path,
1871 event: &FerrumProfileEvent,
1872) -> std::result::Result<(), String> {
1873 event.validate().map_err(|err| err.to_string())?;
1874 ferrum_bench_core::write_jsonl_records(
1875 path,
1876 ferrum_bench_core::JsonlJournalOpenMode::Append,
1877 std::slice::from_ref(event),
1878 )
1879 .map_err(|error| error.to_string())
1880}
1881
1882fn elapsed_us_since(started_at: Instant) -> u64 {
1883 started_at
1884 .elapsed()
1885 .as_micros()
1886 .max(1)
1887 .try_into()
1888 .unwrap_or(u64::MAX)
1889}
1890
1891fn write_chat_request_failure_diagnostics_at_root(
1892 request_dump_dir: Option<&Path>,
1893 admission_summary: Option<&serde_json::Value>,
1894 engine_status: Option<&EngineStatus>,
1895 request_id: &str,
1896 failure_kind: &str,
1897 phase: &str,
1898 error_kind: &str,
1899 message: &str,
1900) -> std::result::Result<(), String> {
1901 let Some(root) = request_dump_dir else {
1902 return Ok(());
1903 };
1904 let bundle_dir = root.join(request_id);
1905 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1906 let message = sanitize_diagnostic_text(message);
1907 let now = chrono::Utc::now();
1908
1909 let bad_scan_path = bundle_dir.join("bad_output_scan.json");
1910 let mut bad_scan = fs::read_to_string(&bad_scan_path)
1911 .ok()
1912 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
1913 .filter(|value| value.is_object())
1914 .unwrap_or_else(|| serde_json::json!({}));
1915 let bad_scan_obj = bad_scan
1916 .as_object_mut()
1917 .expect("bad scan fallback should be an object");
1918 bad_scan_obj.insert(
1919 "schema_version".to_string(),
1920 serde_json::json!(OBSERVABILITY_PROFILE_SCHEMA_VERSION),
1921 );
1922 bad_scan_obj.insert("request_id".to_string(), serde_json::json!(request_id));
1923 bad_scan_obj
1924 .entry("bad_output".to_string())
1925 .or_insert_with(|| serde_json::json!(false));
1926 bad_scan_obj
1927 .entry("bad_text_count".to_string())
1928 .or_insert_with(|| serde_json::json!(0));
1929 bad_scan_obj
1930 .entry("reasons".to_string())
1931 .or_insert_with(|| serde_json::json!([]));
1932 bad_scan_obj
1933 .entry("first_bad_text_span".to_string())
1934 .or_insert(serde_json::Value::Null);
1935 bad_scan_obj.insert("failure_kind".to_string(), serde_json::json!(failure_kind));
1936 bad_scan_obj.insert("failure_phase".to_string(), serde_json::json!(phase));
1937 bad_scan_obj.insert("error_kind".to_string(), serde_json::json!(error_kind));
1938 bad_scan_obj
1939 .entry("output_chars".to_string())
1940 .or_insert_with(|| serde_json::json!(0));
1941 bad_scan_obj
1942 .entry("output_sha256".to_string())
1943 .or_insert_with(|| serde_json::json!(sha256_hex(b"")));
1944 write_json_value(&bad_scan_path, &bad_scan)?;
1945
1946 let diagnostics = if chat_resource_failure_kind(failure_kind) {
1947 chat_resource_failure_diagnostics(
1948 request_id,
1949 failure_kind,
1950 phase,
1951 error_kind,
1952 &message,
1953 now.timestamp_millis(),
1954 admission_summary,
1955 engine_status,
1956 )
1957 } else {
1958 serde_json::json!({
1959 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1960 "entrypoint": "serve",
1961 "request_id": request_id,
1962 "failure_kind": failure_kind,
1963 "phase": phase,
1964 "first_failure_event": {
1965 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1966 "entrypoint": "serve",
1967 "request_id": request_id,
1968 "phase": phase,
1969 "error_kind": error_kind,
1970 "message": message,
1971 "timestamp_unix_ms": now.timestamp_millis()
1972 },
1973 "nearest_request_id": request_id,
1974 "log_excerpt": format!("{phase}: {message}"),
1975 "backtrace_excerpt": null,
1976 "nearest_resource_event": null,
1977 "nearest_memory_snapshot": null
1978 })
1979 };
1980 write_json_value(&bundle_dir.join("failure_diagnostics.json"), &diagnostics)?;
1981 Ok(())
1982}
1983
1984fn chat_resource_failure_diagnostics(
1985 request_id: &str,
1986 failure_kind: &str,
1987 phase: &str,
1988 error_kind: &str,
1989 message: &str,
1990 timestamp_unix_ms: i64,
1991 admission_summary: Option<&serde_json::Value>,
1992 engine_status: Option<&EngineStatus>,
1993) -> serde_json::Value {
1994 let resource_kind = chat_resource_kind_for_failure(failure_kind);
1995 let memory = engine_status
1996 .map(|status| &status.memory_usage)
1997 .map(|memory| {
1998 let current = memory.used_bytes as i64;
1999 let high_water = current.max(0);
2000 serde_json::json!({
2001 "scope": "serve_failure",
2002 "backend": "engine_status",
2003 "current_bytes": current.max(0),
2004 "high_water_bytes": high_water,
2005 "total_bytes": memory.total_bytes,
2006 "free_bytes": memory.free_bytes,
2007 "gpu_memory_bytes": memory.gpu_memory_bytes,
2008 "cpu_memory_bytes": memory.cpu_memory_bytes,
2009 "source": "engine_status"
2010 })
2011 })
2012 .unwrap_or_else(|| {
2013 serde_json::json!({
2014 "scope": "serve_failure",
2015 "backend": "engine_status",
2016 "current_bytes": 0,
2017 "high_water_bytes": 0,
2018 "source": "not_collected"
2019 })
2020 });
2021 let capacity = chat_failure_capacity(resource_kind, admission_summary, engine_status, message);
2022 let needed = capacity
2023 .get("needed")
2024 .and_then(|value| value.as_i64())
2025 .unwrap_or(1)
2026 .max(1);
2027 let capacity_value = capacity
2028 .get("capacity")
2029 .and_then(|value| value.as_i64())
2030 .unwrap_or(0)
2031 .max(0);
2032 serde_json::json!({
2033 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2034 "entrypoint": "serve",
2035 "request_id": request_id,
2036 "failure_kind": failure_kind,
2037 "phase": phase,
2038 "first_failure_event": {
2039 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2040 "entrypoint": "serve",
2041 "request_id": request_id,
2042 "phase": phase,
2043 "error_kind": error_kind,
2044 "message": message,
2045 "timestamp_unix_ms": timestamp_unix_ms
2046 },
2047 "nearest_request_id": request_id,
2048 "log_excerpt": format!("{phase}: {message}"),
2049 "capacity": capacity,
2050 "nearest_resource_event": {
2051 "owner_kind": "request",
2052 "owner_id": request_id,
2053 "resource_kind": resource_kind,
2054 "action": "reject",
2055 "amount": needed,
2056 "before": capacity_value,
2057 "after": capacity_value,
2058 "capacity": capacity_value,
2059 "reason": message
2060 },
2061 "nearest_memory_snapshot": memory
2062 })
2063}
2064
2065fn chat_failure_capacity(
2066 resource_kind: &str,
2067 admission_summary: Option<&serde_json::Value>,
2068 engine_status: Option<&EngineStatus>,
2069 reason: &str,
2070) -> serde_json::Value {
2071 if resource_kind == "device_memory" {
2072 let (needed, available, capacity) = engine_status
2073 .map(|status| {
2074 let memory = &status.memory_usage;
2075 let used = memory.used_bytes as i64;
2076 let available = memory.free_bytes as i64;
2077 let capacity = memory.total_bytes as i64;
2078 (
2079 used.saturating_add(1).max(1),
2080 available.max(0),
2081 capacity.max(0),
2082 )
2083 })
2084 .unwrap_or((1, 0, 0));
2085 return serde_json::json!({
2086 "resource_kind": resource_kind,
2087 "needed": needed,
2088 "available": available,
2089 "capacity": capacity,
2090 "reason": reason
2091 });
2092 }
2093 let capacity = admission_summary
2094 .and_then(|summary| summary.get("effective_max_concurrent"))
2095 .and_then(|value| {
2096 value
2097 .as_i64()
2098 .or_else(|| value.as_u64().map(|value| value as i64))
2099 })
2100 .unwrap_or_else(|| {
2101 engine_status
2102 .map(|status| {
2103 (status.active_requests as i64)
2104 .saturating_add(status.queued_requests as i64)
2105 .saturating_add(1)
2106 })
2107 .unwrap_or(0)
2108 })
2109 .max(0);
2110 let used = engine_status
2111 .map(|status| (status.active_requests as i64).saturating_add(status.queued_requests as i64))
2112 .unwrap_or(0)
2113 .max(0);
2114 serde_json::json!({
2115 "resource_kind": resource_kind,
2116 "needed": 1,
2117 "available": capacity.saturating_sub(used),
2118 "capacity": capacity,
2119 "reason": reason
2120 })
2121}
2122
2123fn chat_resource_failure_kind(failure_kind: &str) -> bool {
2124 matches!(
2125 failure_kind,
2126 "oom" | "prevented_oom" | "admission" | "admission_reject" | "oom_admission"
2127 )
2128}
2129
2130fn chat_resource_kind_for_failure(failure_kind: &str) -> &'static str {
2131 match failure_kind {
2132 "oom" | "prevented_oom" => "device_memory",
2133 _ => "admission_capacity",
2134 }
2135}
2136
2137fn sanitize_diagnostic_text(message: &str) -> String {
2138 let trimmed = message.trim();
2139 if trimmed.is_empty() {
2140 return "generation failed without an error message".to_string();
2141 }
2142 let lower = trimmed.to_ascii_lowercase();
2143 if lower.contains("authorization")
2144 || lower.contains("cookie")
2145 || lower.contains("api_key")
2146 || lower.contains("access_token")
2147 || lower.contains("refresh_token")
2148 || lower.contains("password")
2149 || trimmed.contains("sk-")
2150 {
2151 return "[redacted diagnostic message]".to_string();
2152 }
2153 trimmed.chars().take(2048).collect()
2154}
2155
2156fn sanitized_replay_headers(headers: &HeaderMap) -> serde_json::Value {
2157 let mut result = serde_json::Map::new();
2158 for key in ["content-type", "traceparent", "tracestate"] {
2159 if let Some(value) = headers.get(key).and_then(|value| value.to_str().ok()) {
2160 result.insert(key.to_string(), serde_json::json!(value));
2161 }
2162 }
2163 result.insert("authorization".to_string(), serde_json::json!("[redacted]"));
2164 result.insert("cookie".to_string(), serde_json::json!("[redacted]"));
2165 serde_json::Value::Object(result)
2166}
2167
2168fn sanitized_chat_request_body(request: &ChatCompletionsRequest) -> serde_json::Value {
2169 let mut value = serde_json::to_value(request).unwrap_or_else(|_| {
2170 serde_json::json!({
2171 "model": request.model.clone(),
2172 "stream": request.stream.unwrap_or(false),
2173 "messages": []
2174 })
2175 });
2176 redact_json_value(&mut value, None);
2177 value
2178}
2179
2180fn redact_json_value(value: &mut serde_json::Value, key: Option<&str>) {
2181 if key.is_some_and(is_secret_key) {
2182 *value = serde_json::json!("[redacted]");
2183 return;
2184 }
2185 if matches!(key, Some("content" | "arguments")) && value.is_string() {
2186 *value = serde_json::json!("[redacted]");
2187 return;
2188 }
2189 match value {
2190 serde_json::Value::Object(map) => {
2191 for field in ["content", "arguments"] {
2192 if let Some(chars) = map
2193 .get(field)
2194 .and_then(|child| child.as_str())
2195 .map(|text| text.chars().count())
2196 {
2197 map.insert(field.to_string(), serde_json::json!("[redacted]"));
2198 map.insert(format!("{field}_redacted"), serde_json::json!(true));
2199 map.insert(format!("{field}_chars"), serde_json::json!(chars));
2200 }
2201 }
2202 for (child_key, child) in map.iter_mut() {
2203 redact_json_value(child, Some(child_key.as_str()));
2204 }
2205 }
2206 serde_json::Value::Array(items) => {
2207 for child in items {
2208 redact_json_value(child, None);
2209 }
2210 }
2211 _ => {}
2212 }
2213}
2214
2215fn is_secret_key(key: &str) -> bool {
2216 let normalized = key
2217 .chars()
2218 .filter(|ch| *ch != '-' && *ch != '_')
2219 .flat_map(char::to_lowercase)
2220 .collect::<String>();
2221 matches!(
2222 normalized.as_str(),
2223 "authorization"
2224 | "cookie"
2225 | "secret"
2226 | "apikey"
2227 | "password"
2228 | "accesstoken"
2229 | "refreshtoken"
2230 | "idtoken"
2231 )
2232}
2233
2234fn replay_curl_argv(bundle_dir: &Path) -> Vec<String> {
2235 vec![
2236 "curl".to_string(),
2237 "-sS".to_string(),
2238 "-X".to_string(),
2239 "POST".to_string(),
2240 "http://127.0.0.1:8000/v1/chat/completions".to_string(),
2241 "-H".to_string(),
2242 "content-type: application/json".to_string(),
2243 "--data-binary".to_string(),
2244 format!("@{}", bundle_dir.join("replay_body.json").display()),
2245 ]
2246}
2247
2248fn replay_curl_command(bundle_dir: &Path) -> String {
2249 shell_command(&replay_curl_argv(bundle_dir))
2250}
2251
2252fn replay_bundle_argv(bundle_dir: &Path) -> Vec<String> {
2253 vec![
2254 "cargo".to_string(),
2255 "run".to_string(),
2256 "-p".to_string(),
2257 "ferrum-cli".to_string(),
2258 "--".to_string(),
2259 "replay-bundle".to_string(),
2260 bundle_dir.to_string_lossy().to_string(),
2261 "--out".to_string(),
2262 bundle_dir
2263 .join("engine_replay")
2264 .to_string_lossy()
2265 .to_string(),
2266 "--json".to_string(),
2267 ]
2268}
2269
2270fn shell_command(argv: &[String]) -> String {
2271 argv.iter()
2272 .map(|part| shell_quote(part))
2273 .collect::<Vec<_>>()
2274 .join(" ")
2275}
2276
2277fn shell_quote(value: &str) -> String {
2278 if value
2279 .chars()
2280 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '@'))
2281 {
2282 value.to_string()
2283 } else {
2284 format!("'{}'", value.replace('\'', "'\\''"))
2285 }
2286}
2287
2288fn write_json_value(path: &Path, value: &serde_json::Value) -> std::result::Result<(), String> {
2289 let bytes = serde_json::to_vec_pretty(value).map_err(|err| err.to_string())?;
2290 fs::write(path, [bytes, b"\n".to_vec()].concat()).map_err(|err| err.to_string())
2291}
2292
2293fn bad_output_scan_json(
2294 request_id: &str,
2295 text: &str,
2296 failure_kind: Option<&str>,
2297 output_artifact_bytes: &[u8],
2298) -> serde_json::Value {
2299 let mut reasons = Vec::new();
2300 let mut first_span: Option<serde_json::Value> = None;
2301 for (needle, reason) in [
2302 ("<unk>", "reserved_token"),
2303 ("[PAD", "reserved_token"),
2304 ("<pad>", "reserved_token"),
2305 ("<|endoftext|>", "reserved_token"),
2306 ("<|im_start|>", "reserved_token"),
2307 ("<|im_end|>", "reserved_token"),
2308 ("<|reserved_special_token", "reserved_token"),
2309 ("\u{fffd}", "invalid_utf8"),
2310 ] {
2311 if let Some(index) = text.find(needle) {
2312 reasons.push(reason);
2313 first_span.get_or_insert_with(|| {
2314 serde_json::json!({
2315 "byte_start": index,
2316 "byte_end": index + needle.len(),
2317 "text": needle,
2318 "reason": reason
2319 })
2320 });
2321 }
2322 }
2323 if let Some(index) = first_mojibake_index(text) {
2324 reasons.push("mojibake");
2325 first_span.get_or_insert_with(|| {
2326 serde_json::json!({
2327 "byte_start": index,
2328 "byte_end": index + 1,
2329 "reason": "mojibake"
2330 })
2331 });
2332 }
2333 reasons.sort_unstable();
2334 reasons.dedup();
2335 let bad_output = !reasons.is_empty();
2336 serde_json::json!({
2337 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2338 "request_id": request_id,
2339 "bad_output": bad_output,
2340 "bad_text_count": if bad_output { 1 } else { 0 },
2341 "reasons": reasons,
2342 "first_bad_text_span": first_span,
2343 "failure_kind": failure_kind,
2344 "output_chars": text.chars().count(),
2345 "classified_output_sha256": sha256_hex(text.as_bytes()),
2346 "output_sha256": sha256_hex(output_artifact_bytes)
2347 })
2348}
2349
2350fn first_mojibake_index(text: &str) -> Option<usize> {
2351 let mut chars = text.char_indices().peekable();
2352 while let Some((index, ch)) = chars.next() {
2353 match ch {
2354 '\u{00c2}' | '\u{00c3}' => {
2355 if chars.peek().is_some_and(|(_, next)| !next.is_ascii()) {
2356 return Some(index);
2357 }
2358 }
2359 '\u{00e2}' => {
2360 if chars.peek().is_some_and(|(_, next)| *next == '\u{20ac}') {
2361 return Some(index);
2362 }
2363 }
2364 _ => {}
2365 }
2366 }
2367 None
2368}
2369
2370fn sha256_hex(bytes: &[u8]) -> String {
2371 let mut hasher = Sha256::new();
2372 hasher.update(bytes);
2373 format!("{:x}", hasher.finalize())
2374}
2375
2376struct ParsedChatModelOutput {
2377 visible: ParsedReasoningResponse,
2378 harmony_response: Option<ferrum_types::ApiChatResponse>,
2379}
2380
2381fn parse_chat_model_output(
2382 protocol: ModelOutputProtocol,
2383 text: &str,
2384 started_in_think: bool,
2385 finish_reason: FinishReason,
2386) -> std::result::Result<ParsedChatModelOutput, ServerError> {
2387 match protocol {
2388 ModelOutputProtocol::Text | ModelOutputProtocol::GemmaThought => {
2389 Ok(ParsedChatModelOutput {
2390 visible: parse_model_reasoning_response(protocol, text, started_in_think)
2391 .map_err(|error| ServerError::InternalError(error.to_string()))?,
2392 harmony_response: None,
2393 })
2394 }
2395 ModelOutputProtocol::HarmonyGptOss => {
2396 let parsed = parse_harmony_response_for_finish_reason(text, Some(finish_reason))
2397 .map_err(|error| {
2398 ServerError::InternalError(format!(
2399 "model output did not satisfy the GPT-OSS Harmony protocol: {error}"
2400 ))
2401 })?;
2402 let harmony_response =
2403 parsed
2404 .tool_call
2405 .map(|tool_call| ferrum_types::ApiChatResponse {
2406 message: ferrum_types::ApiChatMessage {
2407 role: ferrum_types::ApiMessageRole::Assistant,
2408 content: String::new(),
2409 name: None,
2410 tool_calls: vec![ferrum_types::ApiToolCall {
2411 id: format!("call_{}", Uuid::new_v4().simple()),
2412 tool_type: "function".to_string(),
2413 function: ferrum_types::ApiFunctionCall {
2414 name: tool_call.name,
2415 arguments: tool_call.arguments_json,
2416 },
2417 }],
2418 tool_call_id: None,
2419 function_call: None,
2420 },
2421 finish_reason: Some("tool_calls".to_string()),
2422 });
2423 Ok(ParsedChatModelOutput {
2424 visible: ParsedReasoningResponse {
2425 content: parsed.content,
2426 reasoning: parsed.reasoning_content,
2427 },
2428 harmony_response,
2429 })
2430 }
2431 }
2432}
2433
2434async fn handle_chat_completions_stream(
2436 state: AppState,
2437 openai_request: ChatCompletionsRequest,
2438 inference_request: InferenceRequest,
2439 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
2440) -> std::result::Result<Response, ServerError> {
2441 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2442
2443 let engine = state.llm.clone().ok_or_else(|| {
2445 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2446 })?;
2447 let request_id = Uuid::new_v4().to_string();
2448 let include_stream_usage = openai_request
2449 .stream_options
2450 .as_ref()
2451 .and_then(|opts| opts.include_usage)
2452 .unwrap_or(false);
2453 let output_contract = EffectiveChatOutputContract::resolve(&openai_request);
2454 let buffer_json_object_stream = matches!(
2455 output_contract,
2456 EffectiveChatOutputContract::JsonObjectContent
2457 );
2458 let buffer_strict_json_schema_stream = matches!(
2459 output_contract,
2460 EffectiveChatOutputContract::StrictJsonSchemaContent
2461 );
2462 let stream_api_request = match inference_request.api_request.as_ref() {
2463 Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
2464 _ => api_chat_request(
2465 &openai_request,
2466 openai_request.tool_choice.as_ref(),
2467 ferrum_types::ApiToolCallProtocol::default(),
2468 ),
2469 };
2470 let buffer_structured_api_stream =
2471 ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
2472 let model_output_protocol = inference_request.sampling_params.model_output_protocol;
2473 let buffer_stream_output = buffer_json_object_stream
2474 || buffer_strict_json_schema_stream
2475 || buffer_structured_api_stream
2476 || model_output_protocol == ModelOutputProtocol::HarmonyGptOss;
2477 let started_in_think =
2480 has_unclosed_model_reasoning_block(model_output_protocol, &inference_request.prompt);
2481 let replay_request_id = inference_request.id.to_string();
2482 let profile_request_model = openai_request.model.clone();
2483 let profile_started_at = Instant::now();
2484 let request_memory_before = request_memory_sample_before(&state);
2485 let mut stream = match engine.infer_stream(inference_request).await {
2486 Ok(stream) => stream,
2487 Err(e) => {
2488 let failure_kind = e.observability_failure_kind();
2489 let error_kind = e.observability_error_kind();
2490 let error_message = e.to_string();
2491 if let Err(err) = write_chat_request_profile_event(
2492 &state,
2493 &replay_request_id,
2494 benchmark_correlation.as_ref(),
2495 &profile_request_model,
2496 true,
2497 "chat_completions_stream_start",
2498 profile_started_at,
2499 ChatRequestProfileTiming::default(),
2500 0,
2501 None,
2502 Some("error"),
2503 Some(ProfileError {
2504 kind: error_kind.to_string(),
2505 message: error_message.clone(),
2506 blocking: false,
2507 }),
2508 ) {
2509 warn!("failed to write chat stream failure profile event: {}", err);
2510 }
2511 let engine_status = if chat_resource_failure_kind(failure_kind) {
2512 Some(engine.status().await)
2513 } else {
2514 None
2515 };
2516 error!(
2517 "Stream generation failed before first chunk: {}",
2518 error_message
2519 );
2520 if let Err(err) = write_chat_request_failure_diagnostics(
2521 &state,
2522 &replay_request_id,
2523 failure_kind,
2524 "chat_completions_stream_start",
2525 error_kind,
2526 &error_message,
2527 engine_status.as_ref(),
2528 ) {
2529 warn!("failed to write chat stream failure diagnostics: {}", err);
2530 }
2531 return Err(server_error_from_ferrum_error(e));
2532 }
2533 };
2534 let request_dump_dir = state.request_dump_dir.clone();
2535 let admission_summary = state
2536 .auto_config
2537 .as_ref()
2538 .map(|config| config.admission_summary_document());
2539 let diagnostics_engine = engine.clone();
2540 let profile_state = state.clone();
2541
2542 tokio::spawn(async move {
2543 let mut current_text = String::new();
2544 let mut output_token_ids = Vec::new();
2545 let mut first_engine_chunk_received_us = None;
2546 let mut first_sse_enqueue_us = None;
2547 let mut sent_reasoning_len = 0usize;
2548 let mut sent_content_len = 0usize;
2549
2550 loop {
2551 let next = tokio::select! {
2552 biased;
2553 _ = tx.closed() => break,
2554 next = stream.next() => next,
2555 };
2556 let Some(result) = next else {
2557 break;
2558 };
2559 match result {
2560 Ok(chunk) => {
2561 if first_engine_chunk_received_us.is_none()
2562 && (chunk.token.is_some() || !chunk.text.is_empty())
2563 {
2564 first_engine_chunk_received_us = Some(elapsed_us_since(profile_started_at));
2565 }
2566 if let Some(token) = chunk.token {
2567 output_token_ids.push(token);
2568 }
2569 if !chunk.text.is_empty() {
2570 current_text.push_str(&chunk.text);
2571
2572 if !buffer_stream_output
2573 && !should_defer_model_reasoning_stream_delta(
2574 model_output_protocol,
2575 ¤t_text,
2576 )
2577 {
2578 let parsed = match parse_model_reasoning_response(
2579 model_output_protocol,
2580 ¤t_text,
2581 started_in_think,
2582 ) {
2583 Ok(parsed) => parsed,
2584 Err(error) => {
2585 let _ = tx.send(Ok(openai_error_sse_event(
2586 error.to_string(),
2587 "internal_server_error",
2588 Some("model_output"),
2589 )));
2590 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2591 break;
2592 }
2593 };
2594 let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
2595 let reasoning_delta =
2596 stream_text_delta(full_reasoning, &mut sent_reasoning_len);
2597 let content_delta =
2598 stream_text_delta(&parsed.content, &mut sent_content_len);
2599 if !reasoning_delta.is_empty() || !content_delta.is_empty() {
2600 let response_chunk = ChatCompletionsResponse {
2602 id: request_id.clone(),
2603 object: "chat.completion.chunk".to_string(),
2604 created: chrono::Utc::now().timestamp() as u64,
2605 model: openai_request.model.clone(),
2606 choices: vec![ChatChoice {
2607 index: 0,
2608 message: None,
2609 delta: Some(ChatMessage {
2610 role: MessageRole::Assistant,
2611 content: content_delta,
2612 reasoning: (!reasoning_delta.is_empty())
2613 .then_some(reasoning_delta),
2614 name: None,
2615 tool_calls: None,
2616 tool_call_id: None,
2617 function_call: None,
2618 }),
2619 finish_reason: None,
2620 }],
2621 usage: None,
2622 };
2623
2624 let sse_event = Event::default()
2625 .json_data(&response_chunk)
2626 .unwrap_or_else(|_| Event::default().data("error"));
2627 if tx.send(Ok(sse_event)).is_err() {
2628 break;
2629 }
2630 first_sse_enqueue_us
2631 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2632 }
2633 }
2634 }
2635
2636 if chunk.finish_reason.is_some() {
2637 let terminal_finish_reason = chunk
2638 .finish_reason
2639 .expect("finish_reason presence checked above");
2640 if let Err(err) = write_chat_prompt_token_evidence(
2641 request_dump_dir.as_ref().map(|root| root.as_path()),
2642 &replay_request_id,
2643 &profile_request_model,
2644 chunk.execution_evidence.as_ref(),
2645 ) {
2646 warn!("failed to write chat stream prompt-token evidence: {}", err);
2647 }
2648 let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
2649 let parsed_model_output = match parse_chat_model_output(
2650 model_output_protocol,
2651 ¤t_text,
2652 started_in_think,
2653 terminal_finish_reason,
2654 ) {
2655 Ok(parsed) => parsed,
2656 Err(error) => {
2657 let error_event = openai_error_sse_event(
2658 stream_validation_error_message(error),
2659 "internal_server_error",
2660 Some("model_output"),
2661 );
2662 let _ = tx.send(Ok(error_event));
2663 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2664 break;
2665 }
2666 };
2667 let mut parsed_final = parsed_model_output.visible;
2668 parsed_final.content = normalize_structured_response_content(
2669 &openai_request,
2670 &parsed_final.content,
2671 );
2672 let structured_chat_response =
2673 finish_reason_allows_structured_api_response(terminal_finish_reason)
2674 .then(|| match chunk.api_response.as_ref() {
2675 _ if model_output_protocol
2678 == ModelOutputProtocol::HarmonyGptOss =>
2679 {
2680 parsed_model_output.harmony_response.clone()
2681 }
2682 Some(ferrum_types::ApiResponse::Chat(response)) => {
2683 Some(response.clone())
2684 }
2685 _ if buffer_structured_api_stream => {
2686 chat_api_response_from_parsed_generated_text(
2687 &stream_api_request,
2688 &parsed_final,
2689 terminal_finish_reason,
2690 )
2691 }
2692 _ => None,
2693 })
2694 .flatten();
2695
2696 if let Some(chat_response) = structured_chat_response.as_ref() {
2697 if let Err(e) =
2698 validate_structured_tool_response(&openai_request, chat_response)
2699 {
2700 let error_event = openai_error_sse_event(
2701 stream_validation_error_message(e),
2702 "internal_server_error",
2703 Some("tool_choice"),
2704 );
2705 let _ = tx.send(Ok(error_event));
2706 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2707 break;
2708 }
2709 } else if tool_choice_required(&openai_request) {
2710 log_required_tool_choice_failure(
2711 &openai_request,
2712 &parsed_final.content,
2713 parsed_final.reasoning.as_deref(),
2714 );
2715 let error_event = openai_error_sse_event(
2716 "model output did not satisfy required tool_choice",
2717 "invalid_request_error",
2718 Some("tool_choice"),
2719 );
2720 let _ = tx.send(Ok(error_event));
2721 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2722 break;
2723 }
2724 if let Err(e) = validate_hard_structured_response(
2725 &openai_request,
2726 &parsed_final.content,
2727 ) {
2728 let error_event = openai_error_sse_event(
2729 stream_validation_error_message(e),
2730 "internal_server_error",
2731 structured_response_error_param(output_contract),
2732 );
2733 let _ = tx.send(Ok(error_event));
2734 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2735 break;
2736 }
2737
2738 if let Some(chat_response) = structured_chat_response.as_ref() {
2739 let mut delta = openai_chat_delta_from_api(&chat_response.message);
2740 if delta.reasoning.is_none() {
2741 delta.reasoning = parsed_final.reasoning.clone();
2742 }
2743 let response_chunk = ChatCompletionsResponse {
2744 id: request_id.clone(),
2745 object: "chat.completion.chunk".to_string(),
2746 created: chrono::Utc::now().timestamp() as u64,
2747 model: openai_request.model.clone(),
2748 choices: vec![ChatChoice {
2749 index: 0,
2750 message: None,
2751 delta: Some(delta),
2752 finish_reason: None,
2753 }],
2754 usage: None,
2755 };
2756
2757 let sse_event = Event::default()
2758 .json_data(&response_chunk)
2759 .unwrap_or_else(|_| Event::default().data("error"));
2760 if tx.send(Ok(sse_event)).is_err() {
2761 break;
2762 }
2763 first_sse_enqueue_us
2764 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2765 } else if buffer_structured_api_stream
2766 && parsed_final.content.trim().is_empty()
2767 {
2768 let error_event = openai_error_sse_event(
2769 "model output did not satisfy tool/function call request",
2770 "internal_server_error",
2771 Some("tool_choice"),
2772 );
2773 let _ = tx.send(Ok(error_event));
2774 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2775 break;
2776 } else if !current_text.is_empty() {
2777 let content_delta =
2781 stream_text_delta(&parsed_final.content, &mut sent_content_len);
2782 let reasoning_delta = stream_text_delta(
2783 parsed_final.reasoning.as_deref().unwrap_or(""),
2784 &mut sent_reasoning_len,
2785 );
2786 if !content_delta.is_empty() || !reasoning_delta.is_empty() {
2787 let response_chunk = ChatCompletionsResponse {
2788 id: request_id.clone(),
2789 object: "chat.completion.chunk".to_string(),
2790 created: chrono::Utc::now().timestamp() as u64,
2791 model: openai_request.model.clone(),
2792 choices: vec![ChatChoice {
2793 index: 0,
2794 message: None,
2795 delta: Some(ChatMessage {
2796 role: MessageRole::Assistant,
2797 content: content_delta,
2798 reasoning: (!reasoning_delta.is_empty())
2799 .then_some(reasoning_delta),
2800 name: None,
2801 tool_calls: None,
2802 tool_call_id: None,
2803 function_call: None,
2804 }),
2805 finish_reason: None,
2806 }],
2807 usage: None,
2808 };
2809
2810 let sse_event = Event::default()
2811 .json_data(&response_chunk)
2812 .unwrap_or_else(|_| Event::default().data("error"));
2813 if tx.send(Ok(sse_event)).is_err() {
2814 break;
2815 }
2816 first_sse_enqueue_us
2817 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2818 }
2819 }
2820 let final_finish_reason = structured_chat_response
2830 .as_ref()
2831 .and_then(|response| response.finish_reason.clone())
2832 .or_else(|| chunk.finish_reason.as_ref().map(finish_reason_to_string))
2833 .or(Some("length".to_string()));
2834 let final_chunk = ChatCompletionsResponse {
2835 id: request_id.clone(),
2836 object: "chat.completion.chunk".to_string(),
2837 created: chrono::Utc::now().timestamp() as u64,
2838 model: openai_request.model.clone(),
2839 choices: vec![ChatChoice {
2840 index: 0,
2841 message: None,
2842 delta: Some(ChatMessage {
2843 role: MessageRole::Assistant,
2844 content: String::new(),
2845 reasoning: None,
2846 name: None,
2847 tool_calls: None,
2848 tool_call_id: None,
2849 function_call: None,
2850 }),
2851 finish_reason: final_finish_reason.clone(),
2852 }],
2853 usage: None,
2854 };
2855
2856 let final_event = Event::default()
2857 .json_data(&final_chunk)
2858 .unwrap_or_else(|_| Event::default().data("error"));
2859 if tx.send(Ok(final_event)).is_ok() {
2860 first_sse_enqueue_us
2861 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2862 }
2863 let completion_token_count = chunk
2864 .usage
2865 .as_ref()
2866 .map(|usage| usage.completion_tokens)
2867 .unwrap_or(output_token_ids.len());
2868 let replay_output_token_ids = chunk
2869 .execution_evidence
2870 .as_ref()
2871 .map(|evidence| evidence.output_token_ids.as_slice())
2872 .filter(|tokens| tokens.len() == completion_token_count)
2873 .unwrap_or(output_token_ids.as_slice());
2874 if let Err(err) = write_chat_request_completion_replay_bundle(
2875 request_dump_dir.as_ref().map(|root| root.as_path()),
2876 &replay_request_id,
2877 &parsed_final.content,
2878 replay_output_token_ids,
2879 final_finish_reason.as_deref(),
2880 ) {
2881 warn!("failed to write chat stream replay bundle: {}", err);
2882 }
2883 if let Err(err) = write_chat_request_profile_event(
2884 &profile_state,
2885 &replay_request_id,
2886 benchmark_correlation.as_ref(),
2887 &profile_request_model,
2888 true,
2889 "chat_completions_stream_complete",
2890 profile_started_at,
2891 ChatRequestProfileTiming {
2892 engine_evidence: chunk.execution_evidence.as_ref(),
2893 first_engine_chunk_received_us,
2894 first_sse_enqueue_us,
2895 },
2896 completion_token_count,
2897 chunk.usage.as_ref(),
2898 final_finish_reason.as_deref(),
2899 None,
2900 ) {
2901 warn!("failed to write chat stream profile event: {}", err);
2902 }
2903 if let Err(err) = maybe_write_first_request_memory_stage(
2904 &profile_state,
2905 &replay_request_id,
2906 benchmark_correlation.as_ref(),
2907 &profile_request_model,
2908 true,
2909 profile_started_at,
2910 request_memory_before,
2911 ) {
2912 warn!("failed to write chat stream memory profile event: {}", err);
2913 }
2914 if include_stream_usage && usage.is_some() {
2915 let usage_chunk = ChatCompletionsResponse {
2916 id: request_id.clone(),
2917 object: "chat.completion.chunk".to_string(),
2918 created: chrono::Utc::now().timestamp() as u64,
2919 model: openai_request.model.clone(),
2920 choices: vec![],
2921 usage,
2922 };
2923 let usage_event = Event::default()
2924 .json_data(&usage_chunk)
2925 .unwrap_or_else(|_| Event::default().data("error"));
2926 let _ = tx.send(Ok(usage_event));
2927 }
2928 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2929 break;
2930 }
2931 }
2932 Err(e) => {
2933 let failure_kind = e.observability_failure_kind();
2934 let error_kind = e.observability_error_kind();
2935 let error_message = e.to_string();
2936 let engine_status = if chat_resource_failure_kind(failure_kind) {
2937 Some(diagnostics_engine.status().await)
2938 } else {
2939 None
2940 };
2941 error!("Stream generation error: {}", error_message);
2942 if let Err(err) = write_chat_request_profile_event(
2943 &profile_state,
2944 &replay_request_id,
2945 benchmark_correlation.as_ref(),
2946 &profile_request_model,
2947 true,
2948 "chat_completions_stream_next",
2949 profile_started_at,
2950 ChatRequestProfileTiming {
2951 engine_evidence: None,
2952 first_engine_chunk_received_us,
2953 first_sse_enqueue_us,
2954 },
2955 output_token_ids.len(),
2956 None,
2957 Some("error"),
2958 Some(ProfileError {
2959 kind: error_kind.to_string(),
2960 message: error_message.clone(),
2961 blocking: false,
2962 }),
2963 ) {
2964 warn!("failed to write chat stream chunk profile event: {}", err);
2965 }
2966 if let Err(err) = write_chat_request_failure_diagnostics_at_root(
2967 request_dump_dir.as_ref().map(|root| root.as_path()),
2968 admission_summary.as_ref(),
2969 engine_status.as_ref(),
2970 &replay_request_id,
2971 failure_kind,
2972 "chat_completions_stream_next",
2973 error_kind,
2974 &error_message,
2975 ) {
2976 warn!(
2977 "failed to write chat stream chunk failure diagnostics: {}",
2978 err
2979 );
2980 }
2981 let _ = tx.send(Ok(openai_error_sse_event(
2982 error_message,
2983 "internal_server_error",
2984 None,
2985 )));
2986 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2987 break;
2988 }
2989 }
2990 }
2991 });
2992
2993 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
2994 let sse_stream = Sse::new(stream);
2995
2996 Ok(sse_stream.into_response())
2997}
2998
2999async fn handle_chat_completions_sync(
3001 state: AppState,
3002 openai_request: ChatCompletionsRequest,
3003 inference_request: InferenceRequest,
3004 session_context: Option<SessionContext>,
3005 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
3006) -> std::result::Result<Response, ServerError> {
3007 info!("Processing non-streaming chat completion");
3008
3009 let engine = state.llm.clone().ok_or_else(|| {
3010 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
3011 })?;
3012 let request_chat_api = inference_request
3013 .api_request
3014 .as_ref()
3015 .and_then(|api_request| match api_request {
3016 ferrum_types::ApiRequest::Chat(chat_request) => {
3017 ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
3018 .then(|| chat_request.clone())
3019 }
3020 _ => None,
3021 });
3022 let model_output_protocol = inference_request.sampling_params.model_output_protocol;
3023 let started_in_think =
3025 has_unclosed_model_reasoning_block(model_output_protocol, &inference_request.prompt);
3026 let replay_request_id = inference_request.id.to_string();
3027 let profile_request_model = openai_request.model.clone();
3028 let profile_started_at = Instant::now();
3029 let request_memory_before = request_memory_sample_before(&state);
3030 match engine.infer(inference_request).await {
3031 Ok(output) => {
3032 let InferenceResponse {
3033 text: output_text,
3034 tokens,
3035 finish_reason,
3036 usage,
3037 api_response,
3038 execution_evidence,
3039 ..
3040 } = output;
3041 if let Err(err) = write_chat_prompt_token_evidence(
3042 state.request_dump_dir.as_ref().map(|root| root.as_path()),
3043 &replay_request_id,
3044 &profile_request_model,
3045 execution_evidence.as_ref(),
3046 ) {
3047 warn!("failed to write chat prompt-token evidence: {}", err);
3048 }
3049
3050 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
3054 let content = strip_after_stop(&output_text, &stop_sequences);
3055 let parsed_model_output = parse_chat_model_output(
3056 model_output_protocol,
3057 &content,
3058 started_in_think,
3059 finish_reason,
3060 )?;
3061 let parsed = parsed_model_output.visible;
3062 let visible_content =
3063 normalize_structured_response_content(&openai_request, &parsed.content);
3064 let mut message = ChatMessage {
3065 role: MessageRole::Assistant,
3066 content: visible_content,
3067 reasoning: parsed.reasoning.clone(),
3068 name: None,
3069 tool_calls: None,
3070 tool_call_id: None,
3071 function_call: None,
3072 };
3073 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
3074 let structured_chat_response =
3075 finish_reason_allows_structured_api_response(finish_reason)
3076 .then(|| match api_response.as_ref() {
3077 _ if model_output_protocol == ModelOutputProtocol::HarmonyGptOss => {
3080 parsed_model_output.harmony_response.clone()
3081 }
3082 Some(ferrum_types::ApiResponse::Chat(chat_response)) => {
3083 Some(chat_response.clone())
3084 }
3085 _ => match request_chat_api.as_ref() {
3086 Some(chat_request) => chat_api_response_from_parsed_generated_text(
3087 chat_request,
3088 &parsed,
3089 finish_reason,
3090 ),
3091 _ => None,
3092 },
3093 })
3094 .flatten();
3095 if let Some(chat_response) = structured_chat_response.as_ref() {
3096 if let Err(error) =
3097 validate_structured_tool_response(&openai_request, chat_response)
3098 {
3099 if let Err(err) = write_chat_request_profile_event(
3100 &state,
3101 &replay_request_id,
3102 benchmark_correlation.as_ref(),
3103 &profile_request_model,
3104 false,
3105 "chat_completions_sync_tool_contract",
3106 profile_started_at,
3107 ChatRequestProfileTiming {
3108 engine_evidence: execution_evidence.as_ref(),
3109 ..Default::default()
3110 },
3111 tokens.len(),
3112 Some(&usage),
3113 Some("error"),
3114 Some(ProfileError {
3115 kind: "tool_contract_failure".to_string(),
3116 message: format!("{error:?}"),
3117 blocking: true,
3118 }),
3119 ) {
3120 warn!("failed to write chat tool-contract profile event: {}", err);
3121 }
3122 return Err(error);
3123 }
3124 message = openai_chat_message_from_api(&chat_response.message);
3125 if message.reasoning.is_none() {
3126 message.reasoning = parsed.reasoning.clone();
3127 }
3128 if let Some(reason) = &chat_response.finish_reason {
3129 openai_finish_reason = reason.clone();
3130 }
3131 } else if tool_choice_required(&openai_request) {
3132 log_required_tool_choice_failure(
3133 &openai_request,
3134 &parsed.content,
3135 parsed.reasoning.as_deref(),
3136 );
3137 if let Err(err) = write_chat_request_profile_event(
3138 &state,
3139 &replay_request_id,
3140 benchmark_correlation.as_ref(),
3141 &profile_request_model,
3142 false,
3143 "chat_completions_sync_tool_choice",
3144 profile_started_at,
3145 ChatRequestProfileTiming {
3146 engine_evidence: execution_evidence.as_ref(),
3147 ..Default::default()
3148 },
3149 tokens.len(),
3150 Some(&usage),
3151 Some("error"),
3152 Some(ProfileError {
3153 kind: "required_tool_failure".to_string(),
3154 message: "model output did not satisfy required tool_choice".to_string(),
3155 blocking: true,
3156 }),
3157 ) {
3158 warn!("failed to write chat tool-choice profile event: {}", err);
3159 }
3160 return Err(ServerError::invalid_request(
3161 "model output did not satisfy required tool_choice",
3162 Some("tool_choice"),
3163 ));
3164 }
3165 if let Err(error) = validate_hard_structured_response(&openai_request, &message.content)
3166 {
3167 if let Err(err) = write_chat_request_profile_event(
3168 &state,
3169 &replay_request_id,
3170 benchmark_correlation.as_ref(),
3171 &profile_request_model,
3172 false,
3173 "chat_completions_sync_structured_output",
3174 profile_started_at,
3175 ChatRequestProfileTiming {
3176 engine_evidence: execution_evidence.as_ref(),
3177 ..Default::default()
3178 },
3179 tokens.len(),
3180 Some(&usage),
3181 Some("error"),
3182 Some(ProfileError {
3183 kind: "structured_output_failure".to_string(),
3184 message: format!("{error:?}"),
3185 blocking: true,
3186 }),
3187 ) {
3188 warn!("failed to write chat strict-schema profile event: {}", err);
3189 }
3190 return Err(error);
3191 }
3192 if let Err(err) = write_chat_request_completion_replay_bundle(
3193 state.request_dump_dir.as_ref().map(|root| root.as_path()),
3194 &replay_request_id,
3195 &message.content,
3196 &tokens,
3197 Some(&openai_finish_reason),
3198 ) {
3199 warn!("failed to write chat completion replay bundle: {}", err);
3200 }
3201 if let Err(err) = write_chat_request_profile_event(
3202 &state,
3203 &replay_request_id,
3204 benchmark_correlation.as_ref(),
3205 &profile_request_model,
3206 false,
3207 "chat_completions_sync_complete",
3208 profile_started_at,
3209 ChatRequestProfileTiming {
3210 engine_evidence: execution_evidence.as_ref(),
3211 ..Default::default()
3212 },
3213 tokens.len(),
3214 Some(&usage),
3215 Some(&openai_finish_reason),
3216 None,
3217 ) {
3218 warn!("failed to write chat sync profile event: {}", err);
3219 }
3220 if let Err(err) = maybe_write_first_request_memory_stage(
3221 &state,
3222 &replay_request_id,
3223 benchmark_correlation.as_ref(),
3224 &profile_request_model,
3225 false,
3226 profile_started_at,
3227 request_memory_before,
3228 ) {
3229 warn!("failed to write chat sync memory profile event: {}", err);
3230 }
3231 state
3232 .cache
3233 .update_session(session_context, message.clone(), &CachePolicy::current());
3234 let response = ChatCompletionsResponse {
3235 id: Uuid::new_v4().to_string(),
3236 object: "chat.completion".to_string(),
3237 created: chrono::Utc::now().timestamp() as u64,
3238 model: openai_request.model,
3239 choices: vec![ChatChoice {
3240 index: 0,
3241 message: Some(message),
3242 delta: None,
3243 finish_reason: Some(openai_finish_reason),
3244 }],
3245 usage: Some(openai_usage_from_token_usage(&usage)),
3246 };
3247
3248 Ok(Json(response).into_response())
3249 }
3250 Err(e) => {
3251 let failure_kind = e.observability_failure_kind();
3252 let error_kind = e.observability_error_kind();
3253 let error_message = e.to_string();
3254 let engine_status = if chat_resource_failure_kind(failure_kind) {
3255 Some(engine.status().await)
3256 } else {
3257 None
3258 };
3259 error!("Generation failed: {}", error_message);
3260 if let Err(err) = write_chat_request_profile_event(
3261 &state,
3262 &replay_request_id,
3263 benchmark_correlation.as_ref(),
3264 &profile_request_model,
3265 false,
3266 "chat_completions_sync",
3267 profile_started_at,
3268 ChatRequestProfileTiming::default(),
3269 0,
3270 None,
3271 Some("error"),
3272 Some(ProfileError {
3273 kind: error_kind.to_string(),
3274 message: error_message.clone(),
3275 blocking: false,
3276 }),
3277 ) {
3278 warn!("failed to write chat sync failure profile event: {}", err);
3279 }
3280 if let Err(err) = write_chat_request_failure_diagnostics(
3281 &state,
3282 &replay_request_id,
3283 failure_kind,
3284 "chat_completions_sync",
3285 error_kind,
3286 &error_message,
3287 engine_status.as_ref(),
3288 ) {
3289 warn!(
3290 "failed to write chat generation failure diagnostics: {}",
3291 err
3292 );
3293 }
3294 Err(server_error_from_ferrum_error(e))
3295 }
3296 }
3297}
3298
3299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3300enum EffectiveChatOutputContract {
3301 RequiredToolCall,
3302 StrictJsonSchemaContent,
3303 JsonObjectContent,
3304 BestEffortJsonSchemaContent,
3305 Text,
3306}
3307
3308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3309enum ChatOutputBudget {
3310 AutoCeiling(u32),
3311 Explicit(u32),
3312}
3313
3314impl ChatOutputBudget {
3315 fn resolve(request: &ChatCompletionsRequest) -> Self {
3316 request
3317 .max_completion_tokens
3318 .or(request.max_tokens)
3319 .map(Self::Explicit)
3320 .unwrap_or(Self::AutoCeiling(DEFAULT_COMPLETION_MAX_TOKENS))
3321 }
3322
3323 const fn ceiling(self) -> u32 {
3324 match self {
3325 Self::AutoCeiling(value) | Self::Explicit(value) => value,
3326 }
3327 }
3328
3329 const fn is_auto(self) -> bool {
3330 matches!(self, Self::AutoCeiling(_))
3331 }
3332}
3333
3334impl EffectiveChatOutputContract {
3335 fn resolve(request: &ChatCompletionsRequest) -> Self {
3336 if tool_choice_required(request) {
3337 return Self::RequiredToolCall;
3338 }
3339 let Some(format) = request.response_format.as_ref() else {
3340 return Self::Text;
3341 };
3342 match format.format_type.as_str() {
3343 "json_schema"
3344 if format
3345 .json_schema
3346 .as_ref()
3347 .and_then(|schema| schema.strict)
3348 .unwrap_or(false) =>
3349 {
3350 Self::StrictJsonSchemaContent
3351 }
3352 "json_schema" => Self::BestEffortJsonSchemaContent,
3353 "json_object" => Self::JsonObjectContent,
3354 _ => Self::Text,
3355 }
3356 }
3357
3358 fn accepts_requested_response_format(self) -> bool {
3359 !matches!(self, Self::RequiredToolCall)
3360 }
3361}
3362
3363#[allow(dead_code)]
3365fn convert_chat_request(
3366 request: &ChatCompletionsRequest,
3367) -> ferrum_types::Result<InferenceRequest> {
3368 convert_chat_request_with_template_model(request, &request.model, None)
3369}
3370
3371fn convert_chat_request_with_template_model(
3378 request: &ChatCompletionsRequest,
3379 template_model_id: &str,
3380 model_template: Option<&ModelChatTemplate>,
3381) -> ferrum_types::Result<InferenceRequest> {
3382 convert_chat_request_with_template_model_and_default(
3383 request,
3384 template_model_id,
3385 model_template,
3386 None,
3387 true,
3388 None,
3389 )
3390}
3391
3392fn convert_chat_request_with_template_model_and_default(
3393 request: &ChatCompletionsRequest,
3394 template_model_id: &str,
3395 model_template: Option<&ModelChatTemplate>,
3396 default_enable_thinking: Option<bool>,
3397 interleaved_system_coalescing: bool,
3398 message_phases: Option<&[Option<AssistantMessagePhase>]>,
3399) -> ferrum_types::Result<InferenceRequest> {
3400 let no_tools: &[ChatTool] = &[];
3401 let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
3402 no_tools
3403 } else {
3404 request.tools.as_deref().unwrap_or_default()
3405 };
3406 let default_tool_choice =
3407 default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
3408 let effective_tool_choice = request
3409 .tool_choice
3410 .as_ref()
3411 .or(default_tool_choice.as_ref());
3412 let functions = request.functions.as_deref().unwrap_or_default();
3413 let model_output_protocol = model_template
3414 .map(|template| template.output_protocol)
3415 .unwrap_or(ModelOutputProtocol::Text);
3416 let output_contract = EffectiveChatOutputContract::resolve(request);
3417 let output_budget = ChatOutputBudget::resolve(request);
3418 let forced_response_format = (model_output_protocol != ModelOutputProtocol::HarmonyGptOss)
3422 .then(|| forced_tool_choice_response_format(request))
3423 .flatten();
3424 let hard_tool_call_contract = forced_response_format.is_some();
3425 let requested_response_format = output_contract
3426 .accepts_requested_response_format()
3427 .then(|| requested_response_format_for_sampling(request))
3428 .transpose()?
3429 .flatten();
3430 let chat_template_options =
3431 chat_template_options_for_request(request, model_template, default_enable_thinking)?;
3432 let response_format = forced_response_format
3433 .or(requested_response_format)
3434 .unwrap_or(ferrum_types::ResponseFormat::Text);
3435 let model_generated_thinking = model_template.is_some_and(|template| {
3436 template.reasoning_protocol == ModelReasoningProtocol::ModelGenerated
3437 && template.reasoning_enabled(chat_template_options.enable_thinking)
3438 });
3439 let reasoning_enabled = model_template
3440 .is_some_and(|template| template.reasoning_enabled(chat_template_options.enable_thinking));
3441 let (render_messages, render_message_phases) = render_messages_with_response_format_instruction(
3442 request,
3443 output_contract,
3444 reasoning_enabled,
3445 message_phases,
3446 );
3447 let prompt = if tools.is_empty() && functions.is_empty() {
3448 render_chat_prompt_with_model_template_options_and_compatibility(
3449 &render_messages,
3450 template_model_id,
3451 model_template,
3452 &chat_template_options,
3453 interleaved_system_coalescing,
3454 Some(&render_message_phases),
3455 )?
3456 } else {
3457 render_chat_prompt_with_tools_and_model_template_compatibility(
3458 &render_messages,
3459 template_model_id,
3460 model_template,
3461 &chat_template_options,
3462 tools,
3463 effective_tool_choice,
3464 functions,
3465 request.function_call.as_ref(),
3466 interleaved_system_coalescing,
3467 Some(&render_message_phases),
3468 )?
3469 };
3470 let tool_call_protocol = model_template
3471 .map(|template| template.tool_call_protocol)
3472 .unwrap_or_default();
3473 let api_chat = api_chat_request(request, effective_tool_choice, tool_call_protocol);
3474 let mut metadata = HashMap::new();
3475 metadata.insert(
3476 "openai_messages".to_string(),
3477 serde_json::to_value(&request.messages)?,
3478 );
3479 if let Some(tools) = &request.tools {
3480 metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
3481 }
3482 if let Some(tool_choice) = effective_tool_choice {
3483 metadata.insert(
3484 "openai_tool_choice".to_string(),
3485 serde_json::to_value(tool_choice)?,
3486 );
3487 }
3488 if let Some(functions) = &request.functions {
3489 metadata.insert(
3490 "openai_legacy_functions".to_string(),
3491 serde_json::to_value(functions)?,
3492 );
3493 }
3494 if let Some(function_call) = &request.function_call {
3495 metadata.insert(
3496 "openai_legacy_function_call".to_string(),
3497 serde_json::to_value(function_call)?,
3498 );
3499 }
3500 if request.ignore_eos.unwrap_or(false) {
3501 metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
3502 }
3503 if output_budget.is_auto() {
3504 metadata.insert(
3505 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
3506 serde_json::json!(true),
3507 );
3508 }
3509 let prompt_opened_thinking = has_unclosed_model_reasoning_block(model_output_protocol, &prompt);
3510 let reasoning_markers = model_reasoning_markers(model_output_protocol);
3511 if !prompt_opened_thinking {
3512 let mut forbidden = reasoning_markers
3513 .map(|(_, close)| vec![close.to_string()])
3514 .unwrap_or_default();
3515 if hard_tool_call_contract {
3516 for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
3517 push_unique_forbidden_token_text(&mut forbidden, token_text);
3518 }
3519 if let Some(eos) = model_template.as_ref().and_then(|template| {
3520 template
3521 .eos_token
3522 .as_deref()
3523 .filter(|token| !token.is_empty())
3524 }) {
3525 push_unique_forbidden_token_text(&mut forbidden, eos);
3526 }
3527 }
3528 if model_output_protocol == ModelOutputProtocol::Text
3529 && chat_template_options.enable_thinking == Some(false)
3530 {
3531 push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
3532 }
3533 metadata.insert(
3534 INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
3535 serde_json::json!(forbidden),
3536 );
3537 }
3538 let structured_output = !matches!(response_format, ferrum_types::ResponseFormat::Text);
3539 let structured_output_after_reasoning = structured_output
3540 && model_output_protocol == ModelOutputProtocol::Text
3541 && (prompt_opened_thinking || model_generated_thinking);
3542 let structured_output_start =
3543 if structured_output && model_output_protocol == ModelOutputProtocol::HarmonyGptOss {
3544 StructuredOutputStart::HarmonyFinal
3545 } else if structured_output && model_output_protocol == ModelOutputProtocol::GemmaThought {
3546 let (opening, closing) = reasoning_markers.expect("Gemma thought markers");
3547 if prompt_opened_thinking {
3548 StructuredOutputStart::AfterDelimiter(closing.to_string())
3549 } else if prompt.trim_end().ends_with(closing) {
3550 StructuredOutputStart::Immediate
3551 } else {
3552 StructuredOutputStart::AfterReasoningEnvelope {
3553 opening: opening.to_string(),
3554 closing: closing.to_string(),
3555 allow_reasoning: reasoning_enabled,
3556 }
3557 }
3558 } else if structured_output_after_reasoning {
3559 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
3560 } else {
3561 StructuredOutputStart::Immediate
3562 };
3563 let delayed_grammar = matches!(
3564 structured_output_start,
3565 StructuredOutputStart::AfterDelimiter(_)
3566 | StructuredOutputStart::AfterReasoningEnvelope { .. }
3567 );
3568 let response_completion_boundary = if let Some((_, closing)) =
3569 reasoning_markers.filter(|_| prompt_opened_thinking || delayed_grammar)
3570 {
3571 ResponseCompletionBoundary::AfterDelimiterAndPayload {
3572 delimiter: closing.to_string(),
3573 alternate_envelope: api_chat.generated_response_envelope(),
3574 }
3575 } else {
3576 ResponseCompletionBoundary::Immediate
3577 };
3578
3579 Ok(InferenceRequest {
3580 id: RequestId(Uuid::new_v4()),
3581 model_id: ModelId(request.model.clone()),
3582 prompt,
3583 sampling_params: SamplingParams {
3584 max_tokens: output_budget.ceiling() as usize,
3585 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
3586 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
3587 top_k: request
3588 .top_k
3589 .filter(|value| *value > 0)
3590 .and_then(|value| usize::try_from(value).ok()),
3591 repetition_penalty: request
3592 .repetition_penalty
3593 .unwrap_or(DEFAULT_CHAT_REPETITION_PENALTY),
3594 presence_penalty: request.presence_penalty.unwrap_or(0.0),
3595 frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
3596 stop_sequences: request.stop.clone().unwrap_or_default(),
3597 seed: request.seed,
3598 min_p: request.min_p.filter(|value| *value > 0.0),
3599 tfs: None,
3600 typical_p: None,
3601 mirostat: None,
3602 response_format,
3603 structured_output_start,
3604 response_completion_boundary,
3605 model_output_protocol,
3606 },
3607 stream: request.stream.unwrap_or(false),
3608 priority: Priority::Normal, client_id: None,
3610 session_id: None,
3611 created_at: chrono::Utc::now(),
3612 api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
3613 evidence_request: Default::default(),
3614 metadata,
3615 })
3616}
3617
3618fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
3619 if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
3620 tokens.push(token.to_string());
3621 }
3622}
3623
3624fn default_auto_tool_choice_for_tools(
3625 tools: &[ChatTool],
3626 choice: Option<&ToolChoice>,
3627) -> Option<ToolChoice> {
3628 if choice.is_none() && !tools.is_empty() {
3629 Some(ToolChoice::Mode("auto".to_string()))
3630 } else {
3631 None
3632 }
3633}
3634
3635fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
3636 matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
3637}
3638
3639fn tool_choice_none_hides_tools(
3640 choice: Option<&ToolChoice>,
3641 model_template: Option<&ModelChatTemplate>,
3642) -> bool {
3643 tool_choice_none(choice)
3644 && model_template
3645 .map(|template| template.template.contains("tools_in_user_message"))
3646 .unwrap_or(false)
3647}
3648
3649fn chat_template_options_for_request(
3650 request: &ChatCompletionsRequest,
3651 model_template: Option<&ModelChatTemplate>,
3652 default_enable_thinking: Option<bool>,
3653) -> ferrum_types::Result<ChatTemplateOptions> {
3654 let mut options = ChatTemplateOptions::default_for_template(model_template);
3655 options.enable_thinking = default_enable_thinking;
3656 let Some(kwargs) = request.chat_template_kwargs.as_ref() else {
3657 return Ok(options);
3658 };
3659 if let Some(value) = kwargs.get("enable_thinking") {
3660 let Some(enable_thinking) = value.as_bool() else {
3661 return Err(Error::invalid_request(
3662 "chat_template_kwargs.enable_thinking must be a boolean",
3663 ));
3664 };
3665 options.enable_thinking = Some(enable_thinking);
3666 }
3667 if let Some(value) = kwargs.get("reasoning_effort") {
3668 let Some(reasoning_effort) = value.as_str() else {
3669 return Err(Error::invalid_request(
3670 "chat_template_kwargs.reasoning_effort must be one of: minimal, low, medium, high, xhigh",
3671 ));
3672 };
3673 options.reasoning_effort = Some(reasoning_effort.parse::<ReasoningEffort>().map_err(
3674 |error| {
3675 Error::invalid_request(format!("chat_template_kwargs.reasoning_effort: {error}"))
3676 },
3677 )?);
3678 }
3679 Ok(options)
3680}
3681
3682fn render_messages_with_response_format_instruction(
3683 request: &ChatCompletionsRequest,
3684 output_contract: EffectiveChatOutputContract,
3685 reasoning_enabled: bool,
3686 message_phases: Option<&[Option<AssistantMessagePhase>]>,
3687) -> (Vec<ChatMessage>, Vec<Option<AssistantMessagePhase>>) {
3688 let mut phases = message_phases
3689 .map(ToOwned::to_owned)
3690 .unwrap_or_else(|| vec![None; request.messages.len()]);
3691 debug_assert_eq!(phases.len(), request.messages.len());
3692 let Some(instruction) =
3693 response_format_prompt_instruction(request, output_contract, reasoning_enabled)
3694 else {
3695 return (request.messages.clone(), phases);
3696 };
3697 let mut messages = request.messages.clone();
3698 let leading_systems = messages
3699 .iter()
3700 .take_while(|message| message.role == MessageRole::System)
3701 .count();
3702 let mut system_parts = Vec::with_capacity(leading_systems + 1);
3703 system_parts.push(instruction);
3704 system_parts.extend(
3705 messages
3706 .drain(..leading_systems)
3707 .map(|message| message.content)
3708 .filter(|content| !content.is_empty()),
3709 );
3710 phases.drain(..leading_systems);
3711 messages.insert(
3712 0,
3713 ChatMessage {
3714 role: MessageRole::System,
3715 content: system_parts.join("\n\n"),
3716 reasoning: None,
3717 name: None,
3718 tool_calls: None,
3719 tool_call_id: None,
3720 function_call: None,
3721 },
3722 );
3723 phases.insert(0, None);
3724 (messages, phases)
3725}
3726
3727fn response_format_prompt_instruction(
3728 request: &ChatCompletionsRequest,
3729 output_contract: EffectiveChatOutputContract,
3730 reasoning_enabled: bool,
3731) -> Option<String> {
3732 if !output_contract.accepts_requested_response_format() {
3733 return None;
3734 }
3735 if let Some(format) = request.response_format.as_ref() {
3736 return match format.format_type.as_str() {
3737 "json_object" => Some(
3738 "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."
3739 .to_string(),
3740 ),
3741 "json_schema" => {
3742 let schema = format.json_schema.as_ref()?.schema.as_ref()?;
3743 let schema_text = serde_json::to_string(schema).ok()?;
3744 Some(if reasoning_enabled {
3745 format!(
3746 "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}"
3747 )
3748 } else {
3749 format!(
3750 "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}"
3751 )
3752 })
3753 }
3754 _ => None,
3755 };
3756 }
3757 None
3758}
3759
3760fn forced_tool_choice_response_format(
3761 request: &ChatCompletionsRequest,
3762) -> Option<ferrum_types::ResponseFormat> {
3763 let selected_tool = selected_tool_for_forced_tool_choice(request)?;
3764 let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
3765 serde_json::to_string(&schema)
3766 .ok()
3767 .map(ferrum_types::ResponseFormat::JsonSchema)
3768}
3769
3770fn requested_response_format_for_sampling(
3771 request: &ChatCompletionsRequest,
3772) -> ferrum_types::Result<Option<ferrum_types::ResponseFormat>> {
3773 let Some(format) = request.response_format.as_ref() else {
3774 return Ok(None);
3775 };
3776 match format.format_type.as_str() {
3777 "json_object" => Ok(Some(ferrum_types::ResponseFormat::JsonObject)),
3778 "json_schema" => {
3779 let Some(schema) = format.json_schema.as_ref() else {
3780 return Err(Error::invalid_request(
3781 "response_format.json_schema.schema is required",
3782 ));
3783 };
3784 if !schema.strict.unwrap_or(false) {
3785 return Ok(None);
3786 }
3787 let Some(schema_value) = schema.schema.as_ref() else {
3788 return Err(Error::invalid_request(
3789 "response_format.json_schema.schema is required",
3790 ));
3791 };
3792 serde_json::to_string(schema_value)
3793 .map(|schema| Some(ferrum_types::ResponseFormat::JsonSchema(schema)))
3794 .map_err(|err| Error::invalid_request(err.to_string()))
3795 }
3796 _ => Ok(None),
3797 }
3798}
3799
3800fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
3801 match request.tool_choice.as_ref()? {
3802 ToolChoice::Function {
3803 tool_type,
3804 function,
3805 } if tool_type == "function" => request
3806 .tools
3807 .as_ref()?
3808 .iter()
3809 .find(|tool| tool.function.name == function.name),
3810 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
3811 single_function_tool(request.tools.as_deref()?)
3812 }
3813 _ => None,
3814 }
3815}
3816
3817fn guided_tool_arguments_schema(
3818 parameters: Option<&serde_json::Value>,
3819) -> Option<serde_json::Value> {
3820 let mut schema = parameters?.clone();
3821 bound_unconstrained_tool_argument_strings(
3822 &mut schema,
3823 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
3824 );
3825 Some(schema)
3826}
3827
3828fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
3829 match value {
3830 serde_json::Value::Object(map) => {
3831 let is_string = map
3832 .get("type")
3833 .and_then(serde_json::Value::as_str)
3834 .is_some_and(|ty| ty == "string");
3835 let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
3836 if is_string && !has_finite_string_shape {
3837 map.insert(
3838 "maxLength".to_string(),
3839 serde_json::Value::Number(default_max.into()),
3840 );
3841 }
3842 if let Some(properties) = map
3843 .get_mut("properties")
3844 .and_then(serde_json::Value::as_object_mut)
3845 {
3846 for property in properties.values_mut() {
3847 bound_unconstrained_tool_argument_strings(property, default_max);
3848 }
3849 }
3850 if let Some(items) = map.get_mut("items") {
3851 bound_unconstrained_tool_argument_strings(items, default_max);
3852 }
3853 }
3854 serde_json::Value::Array(items) => {
3855 for item in items {
3856 bound_unconstrained_tool_argument_strings(item, default_max);
3857 }
3858 }
3859 _ => {}
3860 }
3861}
3862
3863fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
3864 let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
3865 let tool = function_tools.next()?;
3866 function_tools.next().is_none().then_some(tool)
3867}
3868
3869fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
3870 if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
3871 let delta = text[*sent_len..].to_string();
3872 *sent_len = text.len();
3873 return delta;
3874 }
3875 *sent_len = text.len();
3876 String::new()
3877}
3878
3879fn chat_api_response_from_parsed_generated_text(
3880 chat_request: &ferrum_types::ApiChatRequest,
3881 parsed: &ParsedReasoningResponse,
3882 finish_reason: FinishReason,
3883) -> Option<ferrum_types::ApiChatResponse> {
3884 parsed
3885 .reasoning
3886 .as_deref()
3887 .and_then(|reasoning| {
3888 ferrum_types::chat_api_response_from_generated_text(
3889 chat_request,
3890 reasoning,
3891 finish_reason,
3892 )
3893 })
3894 .or_else(|| {
3895 ferrum_types::chat_api_response_from_generated_text(
3896 chat_request,
3897 &parsed.content,
3898 finish_reason,
3899 )
3900 })
3901}
3902
3903fn finish_reason_allows_structured_api_response(finish_reason: FinishReason) -> bool {
3904 matches!(finish_reason, FinishReason::Stop | FinishReason::EOS)
3905}
3906
3907fn log_required_tool_choice_failure(
3908 request: &ChatCompletionsRequest,
3909 content: &str,
3910 reasoning: Option<&str>,
3911) {
3912 warn!(
3913 model = %request.model,
3914 content_len = content.len(),
3915 content_head = %log_excerpt(content, 512),
3916 reasoning_len = reasoning.map(str::len).unwrap_or(0),
3917 reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
3918 "model output did not satisfy required tool_choice"
3919 );
3920}
3921
3922fn log_excerpt(value: &str, max_chars: usize) -> String {
3923 let mut out = value.chars().take(max_chars).collect::<String>();
3924 if value.chars().count() > max_chars {
3925 out.push_str("...");
3926 }
3927 out
3928}
3929
3930fn normalize_structured_response_content(
3931 request: &ChatCompletionsRequest,
3932 content: &str,
3933) -> String {
3934 match EffectiveChatOutputContract::resolve(request) {
3935 EffectiveChatOutputContract::BestEffortJsonSchemaContent => {
3936 extract_json_object_text(content)
3937 .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
3938 }
3939 EffectiveChatOutputContract::RequiredToolCall
3940 | EffectiveChatOutputContract::StrictJsonSchemaContent
3941 | EffectiveChatOutputContract::JsonObjectContent
3942 | EffectiveChatOutputContract::Text => content.to_string(),
3943 }
3944}
3945
3946fn extract_json_object_text(text: &str) -> Option<String> {
3947 let text = strip_markdown_json_fence(text.trim());
3948 if serde_json::from_str::<serde_json::Value>(&text)
3949 .ok()
3950 .filter(|value| value.is_object())
3951 .is_some()
3952 {
3953 return Some(text.to_string());
3954 }
3955
3956 let start = text.find('{')?;
3957 let mut depth = 0usize;
3958 let mut in_string = false;
3959 let mut escaped = false;
3960 for (offset, ch) in text[start..].char_indices() {
3961 if in_string {
3962 if escaped {
3963 escaped = false;
3964 } else if ch == '\\' {
3965 escaped = true;
3966 } else if ch == '"' {
3967 in_string = false;
3968 }
3969 continue;
3970 }
3971 match ch {
3972 '"' => in_string = true,
3973 '{' => depth += 1,
3974 '}' => {
3975 depth = depth.saturating_sub(1);
3976 if depth == 0 {
3977 let end = start + offset + ch.len_utf8();
3978 let candidate = &text[start..end];
3979 if serde_json::from_str::<serde_json::Value>(candidate)
3980 .ok()
3981 .filter(|value| value.is_object())
3982 .is_some()
3983 {
3984 return Some(candidate.to_string());
3985 }
3986 }
3987 }
3988 _ => {}
3989 }
3990 }
3991 None
3992}
3993
3994fn api_chat_request(
3995 request: &ChatCompletionsRequest,
3996 effective_tool_choice: Option<&ToolChoice>,
3997 tool_call_protocol: ferrum_types::ApiToolCallProtocol,
3998) -> ferrum_types::ApiChatRequest {
3999 ferrum_types::ApiChatRequest {
4000 messages: request.messages.iter().map(api_chat_message).collect(),
4001 tools: request
4002 .tools
4003 .as_deref()
4004 .unwrap_or_default()
4005 .iter()
4006 .map(api_tool)
4007 .collect(),
4008 tool_choice: effective_tool_choice.map(api_tool_choice),
4009 tool_call_protocol,
4010 legacy_functions: request
4011 .functions
4012 .as_deref()
4013 .unwrap_or_default()
4014 .iter()
4015 .map(api_function)
4016 .collect(),
4017 legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
4018 response_format: request.response_format.as_ref().map(api_response_format),
4019 stream_options: request.stream_options.as_ref().map(|opts| {
4020 ferrum_types::ApiStreamOptions {
4021 include_usage: opts.include_usage,
4022 }
4023 }),
4024 }
4025}
4026
4027fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
4028 ferrum_types::ApiChatMessage {
4029 role: match message.role {
4030 MessageRole::System => ferrum_types::ApiMessageRole::System,
4031 MessageRole::User => ferrum_types::ApiMessageRole::User,
4032 MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
4033 MessageRole::Function => ferrum_types::ApiMessageRole::Function,
4034 MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
4035 },
4036 content: message.content.clone(),
4037 name: message.name.clone(),
4038 tool_calls: message
4039 .tool_calls
4040 .as_deref()
4041 .unwrap_or_default()
4042 .iter()
4043 .map(api_tool_call)
4044 .collect(),
4045 tool_call_id: message.tool_call_id.clone(),
4046 function_call: message.function_call.as_ref().map(api_function_call),
4047 }
4048}
4049
4050fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
4051 ferrum_types::ApiTool {
4052 tool_type: tool.tool_type.clone(),
4053 function: api_function(&tool.function),
4054 }
4055}
4056
4057fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
4058 ferrum_types::ApiFunction {
4059 name: function.name.clone(),
4060 description: function.description.clone(),
4061 parameters: function.parameters.clone(),
4062 strict: function.strict,
4063 }
4064}
4065
4066fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
4067 match choice {
4068 ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
4069 ToolChoice::Function {
4070 tool_type,
4071 function,
4072 } => ferrum_types::ApiToolChoice::Function {
4073 tool_type: tool_type.clone(),
4074 function: ferrum_types::ApiToolChoiceFunction {
4075 name: function.name.clone(),
4076 },
4077 },
4078 }
4079}
4080
4081fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
4082 match choice {
4083 FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
4084 FunctionCallChoice::Function { name } => {
4085 ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
4086 }
4087 }
4088}
4089
4090fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
4091 ferrum_types::ApiToolCall {
4092 id: tool_call.id.clone(),
4093 tool_type: tool_call.tool_type.clone(),
4094 function: api_function_call(&tool_call.function),
4095 }
4096}
4097
4098fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
4099 ferrum_types::ApiFunctionCall {
4100 name: function_call.name.clone(),
4101 arguments: function_call.arguments.clone(),
4102 }
4103}
4104
4105fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4106 ChatMessage {
4107 role: openai_message_role_from_api(message.role),
4108 content: message.content.clone(),
4109 reasoning: None,
4110 name: message.name.clone(),
4111 tool_calls: if message.tool_calls.is_empty() {
4112 None
4113 } else {
4114 Some(
4115 message
4116 .tool_calls
4117 .iter()
4118 .map(openai_tool_call_from_api)
4119 .collect(),
4120 )
4121 },
4122 tool_call_id: message.tool_call_id.clone(),
4123 function_call: message
4124 .function_call
4125 .as_ref()
4126 .map(openai_function_call_from_api),
4127 }
4128}
4129
4130fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
4131 match role {
4132 ferrum_types::ApiMessageRole::System => MessageRole::System,
4133 ferrum_types::ApiMessageRole::User => MessageRole::User,
4134 ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
4135 ferrum_types::ApiMessageRole::Function => MessageRole::Function,
4136 ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
4137 }
4138}
4139
4140fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
4141 ChatToolCall {
4142 index: None,
4143 id: tool_call.id.clone(),
4144 tool_type: tool_call.tool_type.clone(),
4145 function: openai_function_call_from_api(&tool_call.function),
4146 }
4147}
4148
4149fn openai_tool_call_delta_from_api(
4150 index: usize,
4151 tool_call: &ferrum_types::ApiToolCall,
4152) -> ChatToolCall {
4153 ChatToolCall {
4154 index: Some(usize_to_u32_saturating(index)),
4155 id: tool_call.id.clone(),
4156 tool_type: tool_call.tool_type.clone(),
4157 function: openai_function_call_from_api(&tool_call.function),
4158 }
4159}
4160
4161fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4162 let mut delta = openai_chat_message_from_api(message);
4163 if !message.tool_calls.is_empty() {
4164 delta.tool_calls = Some(
4165 message
4166 .tool_calls
4167 .iter()
4168 .enumerate()
4169 .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
4170 .collect(),
4171 );
4172 }
4173 delta
4174}
4175
4176fn openai_function_call_from_api(
4177 function_call: &ferrum_types::ApiFunctionCall,
4178) -> ChatFunctionCall {
4179 ChatFunctionCall {
4180 name: function_call.name.clone(),
4181 arguments: function_call.arguments.clone(),
4182 }
4183}
4184
4185fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
4186 ferrum_types::ApiResponseFormat {
4187 format_type: format.format_type.clone(),
4188 json_schema: format
4189 .json_schema
4190 .as_ref()
4191 .map(|schema| ferrum_types::ApiJsonSchema {
4192 name: schema.name.clone(),
4193 schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
4194 strict: schema.strict,
4195 }),
4196 }
4197}
4198
4199fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
4200 if request.messages.is_empty() {
4201 return Err(ServerError::invalid_request(
4202 "messages array must not be empty",
4203 Some("messages"),
4204 ));
4205 }
4206
4207 if let Some(n) = request.n {
4208 if n != 1 {
4209 return Err(ServerError::unsupported_feature(
4210 "only n=1 is supported for chat completions",
4211 Some("n"),
4212 ));
4213 }
4214 }
4215
4216 if request
4217 .logit_bias
4218 .as_ref()
4219 .is_some_and(|bias| !bias.is_empty())
4220 {
4221 return Err(ServerError::unsupported_feature(
4222 "logit_bias is not supported",
4223 Some("logit_bias"),
4224 ));
4225 }
4226 if request.logprobs.unwrap_or(false) {
4227 return Err(ServerError::unsupported_feature(
4228 "logprobs is not supported",
4229 Some("logprobs"),
4230 ));
4231 }
4232 if request.top_logprobs.unwrap_or(0) > 0 {
4233 return Err(ServerError::unsupported_feature(
4234 "top_logprobs is not supported",
4235 Some("top_logprobs"),
4236 ));
4237 }
4238
4239 if let Some(top_k) = request.top_k {
4240 if top_k < -1 {
4241 return Err(ServerError::invalid_request(
4242 "top_k must be -1, 0, or a positive integer",
4243 Some("top_k"),
4244 ));
4245 }
4246 }
4247 if let Some(min_p) = request.min_p {
4248 if !min_p.is_finite() || !(0.0..=1.0).contains(&min_p) {
4249 return Err(ServerError::invalid_request(
4250 "min_p must be in range [0, 1]",
4251 Some("min_p"),
4252 ));
4253 }
4254 }
4255 if let Some(repetition_penalty) = request.repetition_penalty {
4256 if !repetition_penalty.is_finite() || repetition_penalty <= 0.0 {
4257 return Err(ServerError::invalid_request(
4258 "repetition_penalty must be positive",
4259 Some("repetition_penalty"),
4260 ));
4261 }
4262 }
4263 if let Some(presence_penalty) = request.presence_penalty {
4264 if !presence_penalty.is_finite() || !(-2.0..=2.0).contains(&presence_penalty) {
4265 return Err(ServerError::invalid_request(
4266 "presence_penalty must be in range [-2, 2]",
4267 Some("presence_penalty"),
4268 ));
4269 }
4270 }
4271 if let Some(frequency_penalty) = request.frequency_penalty {
4272 if !frequency_penalty.is_finite() || !(-2.0..=2.0).contains(&frequency_penalty) {
4273 return Err(ServerError::invalid_request(
4274 "frequency_penalty must be in range [-2, 2]",
4275 Some("frequency_penalty"),
4276 ));
4277 }
4278 }
4279
4280 if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
4281 return Err(ServerError::invalid_request(
4282 "stream_options is only valid when stream=true",
4283 Some("stream_options"),
4284 ));
4285 }
4286 ensure_response_format_supported(request)?;
4287
4288 if let Some(tools) = &request.tools {
4289 for tool in tools {
4290 if tool.tool_type != "function" {
4291 return Err(ServerError::unsupported_feature(
4292 "only function tools are supported",
4293 Some("tools"),
4294 ));
4295 }
4296 }
4297 }
4298
4299 if let Some(choice) = &request.tool_choice {
4300 match choice {
4301 ToolChoice::Mode(mode)
4302 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4303 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
4304 if request.tools.as_deref().unwrap_or_default().is_empty() {
4305 return Err(ServerError::invalid_request(
4306 "tool_choice=required requires at least one function tool",
4307 Some("tool_choice"),
4308 ));
4309 }
4310 }
4311 ToolChoice::Mode(_) => {
4312 return Err(ServerError::unsupported_feature(
4313 "unsupported tool_choice mode",
4314 Some("tool_choice"),
4315 ));
4316 }
4317 ToolChoice::Function {
4318 tool_type,
4319 function,
4320 } => {
4321 if tool_type != "function" {
4322 return Err(ServerError::unsupported_feature(
4323 "only function tool_choice is supported",
4324 Some("tool_choice"),
4325 ));
4326 }
4327 let declared = request
4328 .tools
4329 .as_deref()
4330 .unwrap_or_default()
4331 .iter()
4332 .any(|tool| tool.function.name == function.name);
4333 if !declared {
4334 return Err(ServerError::invalid_request(
4335 "tool_choice selects a function that is not declared in tools",
4336 Some("tool_choice"),
4337 ));
4338 }
4339 }
4340 }
4341 }
4342
4343 if let Some(choice) = &request.function_call {
4344 match choice {
4345 FunctionCallChoice::Mode(mode)
4346 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4347 FunctionCallChoice::Mode(_) => {
4348 return Err(ServerError::unsupported_feature(
4349 "unsupported function_call mode",
4350 Some("function_call"),
4351 ));
4352 }
4353 FunctionCallChoice::Function { name } => {
4354 let declared = request
4355 .functions
4356 .as_deref()
4357 .unwrap_or_default()
4358 .iter()
4359 .any(|function| function.name == *name);
4360 if !declared {
4361 return Err(ServerError::invalid_request(
4362 "function_call selects a function that is not declared in functions",
4363 Some("function_call"),
4364 ));
4365 }
4366 }
4367 }
4368 }
4369
4370 Ok(())
4371}
4372
4373fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
4374 match request.tool_choice.as_ref() {
4375 Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
4376 Some(ToolChoice::Function {
4377 tool_type,
4378 function,
4379 }) => {
4380 tool_type == "function"
4381 && request
4382 .tools
4383 .as_deref()
4384 .unwrap_or_default()
4385 .iter()
4386 .any(|tool| tool.function.name == function.name)
4387 }
4388 _ => false,
4389 }
4390}
4391
4392fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
4393 let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
4394 let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
4395 let total_tokens = usize_to_u32_saturating(usage.total_tokens);
4396 Usage {
4397 prompt_tokens,
4398 completion_tokens,
4399 total_tokens,
4400 }
4401}
4402
4403fn usize_to_u32_saturating(value: usize) -> u32 {
4404 u32::try_from(value).unwrap_or(u32::MAX)
4405}
4406
4407fn ensure_response_format_supported(
4408 request: &ChatCompletionsRequest,
4409) -> std::result::Result<(), ServerError> {
4410 if let Some(rf) = &request.response_format {
4411 match rf.format_type.as_str() {
4412 "text" | "json_object" => {}
4413 "json_schema" => {
4414 let Some(schema_config) = rf.json_schema.as_ref() else {
4415 return Err(ServerError::invalid_request(
4416 "response_format.json_schema.schema is required",
4417 Some("response_format.json_schema"),
4418 ));
4419 };
4420 let Some(schema) = schema_config.schema.as_ref() else {
4421 return Err(ServerError::invalid_request(
4422 "response_format.json_schema.schema is required",
4423 Some("response_format.json_schema"),
4424 ));
4425 };
4426 if schema_config.strict.unwrap_or(false) {
4427 compiled_json_schema_validator(schema).map_err(|reason| {
4428 ServerError::invalid_request(
4429 format!("unsupported strict json_schema: {reason}"),
4430 Some("response_format.json_schema"),
4431 )
4432 })?;
4433 }
4434 }
4435 _ => {
4436 return Err(ServerError::invalid_request(
4437 "unsupported response_format.type",
4438 Some("response_format.type"),
4439 ));
4440 }
4441 }
4442 }
4443 Ok(())
4444}
4445
4446fn strict_json_schema_string(
4447 request: &ChatCompletionsRequest,
4448) -> std::result::Result<Option<String>, ServerError> {
4449 let Some(rf) = &request.response_format else {
4450 return Ok(None);
4451 };
4452 if rf.format_type != "json_schema" {
4453 return Ok(None);
4454 }
4455 let Some(schema) = &rf.json_schema else {
4456 return Err(ServerError::invalid_request(
4457 "response_format.json_schema.schema is required",
4458 Some("response_format.json_schema"),
4459 ));
4460 };
4461 let Some(schema_value) = schema.schema.as_ref() else {
4462 return Err(ServerError::invalid_request(
4463 "response_format.json_schema.schema is required",
4464 Some("response_format.json_schema"),
4465 ));
4466 };
4467 if !schema.strict.unwrap_or(false) {
4468 return Ok(None);
4469 }
4470 serde_json::to_string(schema_value).map(Some).map_err(|e| {
4471 ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
4472 })
4473}
4474
4475fn validate_hard_structured_response(
4476 request: &ChatCompletionsRequest,
4477 content: &str,
4478) -> std::result::Result<(), ServerError> {
4479 match EffectiveChatOutputContract::resolve(request) {
4480 EffectiveChatOutputContract::JsonObjectContent => {
4481 let value = serde_json::from_str::<serde_json::Value>(content).map_err(|error| {
4482 ServerError::InternalError(format!(
4483 "model output did not satisfy response_format.json_object: invalid JSON: {error}"
4484 ))
4485 })?;
4486 if !value.is_object() {
4487 return Err(ServerError::InternalError(
4488 "model output did not satisfy response_format.json_object: root must be an object"
4489 .to_string(),
4490 ));
4491 }
4492 Ok(())
4493 }
4494 EffectiveChatOutputContract::StrictJsonSchemaContent => {
4495 let Some(schema_json) = strict_json_schema_string(request)? else {
4496 return Ok(());
4497 };
4498 let schema: serde_json::Value = serde_json::from_str(&schema_json).map_err(|e| {
4499 ServerError::InternalError(format!(
4500 "strict json_schema could not be reconstructed after request validation: {e}"
4501 ))
4502 })?;
4503 validate_json_text_against_schema(&schema, content).map_err(|reason| {
4504 ServerError::InternalError(format!(
4505 "model output did not satisfy response_format.json_schema.strict: {reason}"
4506 ))
4507 })
4508 }
4509 EffectiveChatOutputContract::RequiredToolCall
4510 | EffectiveChatOutputContract::BestEffortJsonSchemaContent
4511 | EffectiveChatOutputContract::Text => Ok(()),
4512 }
4513}
4514
4515fn structured_response_error_param(contract: EffectiveChatOutputContract) -> Option<&'static str> {
4516 match contract {
4517 EffectiveChatOutputContract::JsonObjectContent => Some("response_format"),
4518 EffectiveChatOutputContract::StrictJsonSchemaContent => Some("response_format.json_schema"),
4519 _ => None,
4520 }
4521}
4522
4523fn validate_structured_tool_response(
4524 request: &ChatCompletionsRequest,
4525 response: &ferrum_types::ApiChatResponse,
4526) -> std::result::Result<(), ServerError> {
4527 let required = tool_choice_required(request);
4528 if response.message.tool_calls.is_empty() {
4529 if required {
4530 return Err(ServerError::invalid_request(
4531 "model output did not satisfy required tool_choice",
4532 Some("tool_choice"),
4533 ));
4534 }
4535 return Ok(());
4536 }
4537
4538 if tool_choice_none(request.tool_choice.as_ref()) {
4539 return Err(ServerError::InternalError(
4540 "model emitted a tool call while tool_choice is 'none'".to_string(),
4541 ));
4542 }
4543
4544 if required {
4545 if !response.message.content.trim().is_empty() {
4546 return Err(ServerError::InternalError(
4547 "required tool response contained assistant content".to_string(),
4548 ));
4549 }
4550 if response.finish_reason.as_deref() != Some("tool_calls") {
4551 return Err(ServerError::InternalError(
4552 "required tool response did not finish with tool_calls".to_string(),
4553 ));
4554 }
4555 }
4556
4557 let tools = request.tools.as_deref().unwrap_or_default();
4558 for call in &response.message.tool_calls {
4559 if call.tool_type != "function" {
4560 return Err(ServerError::InternalError(format!(
4561 "model emitted unsupported tool call type '{}'",
4562 call.tool_type
4563 )));
4564 }
4565 let Some(tool) = tools
4566 .iter()
4567 .find(|tool| tool.tool_type == "function" && tool.function.name == call.function.name)
4568 else {
4569 return Err(ServerError::InternalError(format!(
4570 "model emitted undeclared tool call '{}'",
4571 call.function.name
4572 )));
4573 };
4574 if let Some(ToolChoice::Function {
4575 tool_type,
4576 function,
4577 }) = request.tool_choice.as_ref()
4578 {
4579 if tool_type != "function" || function.name != call.function.name {
4580 return Err(ServerError::InternalError(format!(
4581 "model emitted tool '{}' instead of selected tool '{}'",
4582 call.function.name, function.name
4583 )));
4584 }
4585 }
4586
4587 let arguments: serde_json::Value =
4588 serde_json::from_str(&call.function.arguments).map_err(|e| {
4589 ServerError::InternalError(format!(
4590 "model emitted invalid JSON arguments for tool '{}': {e}",
4591 call.function.name
4592 ))
4593 })?;
4594 if !arguments.is_object() {
4595 return Err(ServerError::InternalError(format!(
4596 "model emitted non-object arguments for tool '{}'",
4597 call.function.name
4598 )));
4599 }
4600 if let Some(schema) = tool.function.parameters.as_ref() {
4601 validate_json_text_against_schema(schema, &call.function.arguments).map_err(
4602 |reason| {
4603 ServerError::InternalError(format!(
4604 "model arguments for tool '{}' did not satisfy its schema: {reason}",
4605 call.function.name
4606 ))
4607 },
4608 )?;
4609 }
4610 }
4611 Ok(())
4612}
4613
4614fn validate_json_text_against_schema(
4615 schema: &serde_json::Value,
4616 content: &str,
4617) -> std::result::Result<(), String> {
4618 let value = serde_json::from_str::<serde_json::Value>(content)
4619 .map_err(|e| format!("invalid JSON: {e}"))?;
4620 compiled_json_schema_validator(schema)?
4621 .validate(&value)
4622 .map_err(|error| error.to_string())
4623}
4624
4625fn compiled_json_schema_validator(
4626 schema: &serde_json::Value,
4627) -> std::result::Result<Arc<jsonschema::Validator>, String> {
4628 let cache_key = serde_json::to_string(schema)
4629 .map_err(|error| format!("could not serialize JSON Schema: {error}"))?;
4630 let cache = JSON_SCHEMA_VALIDATOR_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
4631 let mut validators = cache
4632 .lock()
4633 .map_err(|_| "JSON Schema validator cache lock was poisoned".to_string())?;
4634 if let Some(validator) = validators.get(&cache_key) {
4635 return Ok(Arc::clone(validator));
4636 }
4637
4638 let validator = Arc::new(
4639 jsonschema::validator_for(schema)
4640 .map_err(|error| format!("could not compile JSON Schema: {error}"))?,
4641 );
4642 if validators.len() >= MAX_CACHED_JSON_SCHEMA_VALIDATORS {
4643 validators.clear();
4644 }
4645 validators.insert(cache_key, Arc::clone(&validator));
4646 Ok(validator)
4647}
4648
4649fn stream_validation_error_message(error: ServerError) -> String {
4650 match error {
4651 ServerError::InternalError(message)
4652 | ServerError::NotImplemented(message)
4653 | ServerError::ServiceUnavailable(message)
4654 | ServerError::InvalidRequest { message, .. }
4655 | ServerError::UnsupportedFeature { message, .. } => message,
4656 }
4657}
4658
4659fn server_error_from_ferrum_error(error: Error) -> ServerError {
4660 match error {
4661 Error::RequestValidation { message } => ServerError::invalid_request(message, None),
4662 Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
4663 other => ServerError::InternalError(other.to_string()),
4664 }
4665}
4666
4667fn stream_error_payload(
4668 message: impl Into<String>,
4669 error_type: &str,
4670 param: Option<&str>,
4671) -> OpenAiError {
4672 OpenAiError {
4673 error: OpenAiErrorDetail {
4674 message: message.into(),
4675 error_type: error_type.to_string(),
4676 param: param.map(str::to_string),
4677 code: None,
4678 },
4679 }
4680}
4681
4682fn openai_error_sse_event(
4683 message: impl Into<String>,
4684 error_type: &str,
4685 param: Option<&str>,
4686) -> Event {
4687 Event::default()
4688 .json_data(&stream_error_payload(message, error_type, param))
4689 .unwrap_or_else(|_| Event::default().data("error"))
4690}
4691
4692fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
4693 let prompt = request
4694 .prompt
4695 .as_text()
4696 .expect("completion prompt validated before conversion");
4697 InferenceRequest {
4698 id: RequestId(Uuid::new_v4()),
4699 model_id: ModelId(request.model.clone()),
4700 prompt: prompt.to_string(),
4701 sampling_params: SamplingParams {
4702 max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
4703 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
4704 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
4705 top_k: None,
4706 repetition_penalty: 1.0,
4707 presence_penalty: 0.0,
4708 frequency_penalty: 0.0,
4709 stop_sequences: request.stop.clone().unwrap_or_default(),
4710 seed: None,
4711 min_p: None,
4712 tfs: None,
4713 typical_p: None,
4714 mirostat: None,
4715 response_format: ferrum_types::ResponseFormat::Text,
4716 structured_output_start: StructuredOutputStart::Immediate,
4717 response_completion_boundary: ResponseCompletionBoundary::Immediate,
4718 model_output_protocol: ferrum_types::ModelOutputProtocol::Text,
4719 },
4720 stream: request.stream.unwrap_or(false),
4721 priority: Priority::Normal,
4722 client_id: None,
4723 session_id: None,
4724 created_at: chrono::Utc::now(),
4725 api_request: Some(ferrum_types::ApiRequest::Completion(
4726 ferrum_types::ApiCompletionRequest {
4727 prompt: prompt.to_string(),
4728 response_format: None,
4729 },
4730 )),
4731 evidence_request: Default::default(),
4732 metadata: if request.max_tokens.is_none() {
4733 HashMap::from([(
4734 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
4735 serde_json::json!(true),
4736 )])
4737 } else {
4738 HashMap::new()
4739 },
4740 }
4741}
4742
4743fn resolve_request_model<'a>(
4744 registry: &'a ServedModelRegistry,
4745 request_model: &str,
4746 required_kind: ServedModelKind,
4747) -> std::result::Result<(ModelId, Option<&'a LoraAdapterModel>), ServerError> {
4748 if registry.is_empty() {
4749 return Ok((ModelId::new(request_model), None));
4750 }
4751 let entry = registry
4752 .resolve(request_model, required_kind)
4753 .ok_or_else(|| {
4754 ServerError::invalid_request(format!("unknown model: {request_model}"), Some("model"))
4755 })?;
4756 Ok((entry.engine_model_id().clone(), entry.adapter()))
4757}
4758
4759fn apply_served_model_resolution(
4760 inference_request: &mut InferenceRequest,
4761 engine_model_id: ModelId,
4762 adapter: Option<&LoraAdapterModel>,
4763) {
4764 inference_request.model_id = engine_model_id;
4765 if let Some(adapter) = adapter {
4766 inference_request.metadata.insert(
4767 "ferrum_lora_adapter".to_string(),
4768 serde_json::json!(adapter.name),
4769 );
4770 inference_request.metadata.insert(
4771 "ferrum_lora_model_id".to_string(),
4772 serde_json::json!(adapter.model_id),
4773 );
4774 inference_request.metadata.insert(
4775 "ferrum_lora_path".to_string(),
4776 serde_json::json!(adapter.path),
4777 );
4778 }
4779}
4780
4781async fn handle_completions_sync(
4782 state: AppState,
4783 openai_request: CompletionsRequest,
4784 inference_request: InferenceRequest,
4785) -> std::result::Result<Response, ServerError> {
4786 let engine = state.llm.clone().ok_or_else(|| {
4787 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
4788 })?;
4789 match engine.infer(inference_request).await {
4790 Ok(output) => {
4791 let InferenceResponse {
4792 text: output_text,
4793 finish_reason,
4794 usage,
4795 api_response,
4796 ..
4797 } = output;
4798 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
4799 let mut text = strip_after_stop(&output_text, &stop_sequences);
4800 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
4801 if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
4802 api_response.as_ref()
4803 {
4804 text = strip_after_stop(&completion_response.text, &stop_sequences);
4805 if let Some(reason) = &completion_response.finish_reason {
4806 openai_finish_reason = reason.clone();
4807 }
4808 }
4809 let response = CompletionsResponse {
4810 id: Uuid::new_v4().to_string(),
4811 object: "text_completion".to_string(),
4812 created: chrono::Utc::now().timestamp() as u64,
4813 model: openai_request.model,
4814 choices: vec![CompletionChoice {
4815 text,
4816 index: 0,
4817 finish_reason: Some(openai_finish_reason),
4818 }],
4819 usage: Some(openai_usage_from_token_usage(&usage)),
4820 };
4821 Ok(Json(response).into_response())
4822 }
4823 Err(e) => {
4824 error!("Completion generation failed: {}", e);
4825 Err(ServerError::InternalError(e.to_string()))
4826 }
4827 }
4828}
4829
4830async fn handle_completions_stream(
4831 state: AppState,
4832 openai_request: CompletionsRequest,
4833 inference_request: InferenceRequest,
4834) -> std::result::Result<Response, ServerError> {
4835 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
4836 let engine = state.llm.clone().ok_or_else(|| {
4837 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
4838 })?;
4839 let request_id = Uuid::new_v4().to_string();
4840
4841 tokio::spawn(async move {
4842 match engine.infer_stream(inference_request).await {
4843 Ok(mut stream) => {
4844 while let Some(result) = stream.next().await {
4845 match result {
4846 Ok(chunk) => {
4847 let response_chunk = CompletionsResponse {
4848 id: request_id.clone(),
4849 object: "text_completion".to_string(),
4850 created: chrono::Utc::now().timestamp() as u64,
4851 model: openai_request.model.clone(),
4852 choices: vec![CompletionChoice {
4853 text: chunk.text.clone(),
4854 index: 0,
4855 finish_reason: chunk
4856 .finish_reason
4857 .as_ref()
4858 .map(finish_reason_to_string),
4859 }],
4860 usage: None,
4861 };
4862 let event = Event::default()
4863 .json_data(&response_chunk)
4864 .unwrap_or_else(|_| Event::default().data("error"));
4865 if tx.send(Ok(event)).is_err() {
4866 break;
4867 }
4868 if chunk.finish_reason.is_some() {
4869 if let Some(usage) =
4870 chunk.usage.as_ref().map(openai_usage_from_token_usage)
4871 {
4872 let final_chunk = CompletionsResponse {
4873 id: request_id.clone(),
4874 object: "text_completion".to_string(),
4875 created: chrono::Utc::now().timestamp() as u64,
4876 model: openai_request.model.clone(),
4877 choices: vec![],
4878 usage: Some(usage),
4879 };
4880 let event = Event::default()
4881 .json_data(&final_chunk)
4882 .unwrap_or_else(|_| Event::default().data("error"));
4883 let _ = tx.send(Ok(event));
4884 }
4885 let _ = tx.send(Ok(Event::default().data("[DONE]")));
4886 break;
4887 }
4888 }
4889 Err(e) => {
4890 error!("Completion stream generation error: {}", e);
4891 let _ = tx.send(Ok(openai_error_sse_event(
4892 e.to_string(),
4893 "internal_server_error",
4894 None,
4895 )));
4896 let _ = tx.send(Ok(Event::default().data("[DONE]")));
4897 break;
4898 }
4899 }
4900 }
4901 }
4902 Err(e) => {
4903 error!("Failed to start completion stream: {}", e);
4904 let _ = tx.send(Ok(openai_error_sse_event(
4905 e.to_string(),
4906 "internal_server_error",
4907 None,
4908 )));
4909 let _ = tx.send(Ok(Event::default().data("[DONE]")));
4910 }
4911 }
4912 });
4913
4914 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
4915 Ok(Sse::new(stream).into_response())
4916}
4917
4918async fn completions_handler(
4920 State(state): State<AppState>,
4921 request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
4922) -> std::result::Result<Response, ServerError> {
4923 let Json(request) = request.map_err(|e| {
4924 ServerError::invalid_request(format!("invalid completions request: {e}"), None)
4925 })?;
4926 validate_completion_request(&request)?;
4927 let (engine_model_id, lora_adapter) = resolve_request_model(
4928 &state.served_model_registry,
4929 &request.model,
4930 ServedModelKind::Llm,
4931 )?;
4932 let mut inference_request = convert_completion_request(&request);
4933 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
4934 if request.stream.unwrap_or(false) {
4935 handle_completions_stream(state, request, inference_request).await
4936 } else {
4937 handle_completions_sync(state, request, inference_request).await
4938 }
4939}
4940
4941fn validate_completion_request(
4942 request: &CompletionsRequest,
4943) -> std::result::Result<(), ServerError> {
4944 if request.prompt.as_text().is_none() {
4945 return Err(ServerError::invalid_request(
4946 "only string prompt is supported for completions",
4947 Some("prompt"),
4948 ));
4949 }
4950 if let Some(n) = request.n {
4951 if n != 1 {
4952 return Err(ServerError::unsupported_feature(
4953 "only n=1 is supported for completions",
4954 Some("n"),
4955 ));
4956 }
4957 }
4958 if request.logprobs.is_some() {
4959 return Err(ServerError::unsupported_feature(
4960 "logprobs is not supported for completions",
4961 Some("logprobs"),
4962 ));
4963 }
4964 if request
4965 .logit_bias
4966 .as_ref()
4967 .is_some_and(|bias| !bias.is_empty())
4968 {
4969 return Err(ServerError::unsupported_feature(
4970 "logit_bias is not supported",
4971 Some("logit_bias"),
4972 ));
4973 }
4974 Ok(())
4975}
4976
4977async fn embeddings_handler(
4979 State(state): State<AppState>,
4980 request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
4981) -> std::result::Result<Response, ServerError> {
4982 let Json(request) = request.map_err(|e| {
4983 ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
4984 })?;
4985
4986 let span = span!(Level::INFO, "embeddings", model = %request.model);
4987 let _enter = span.enter();
4988
4989 validate_embeddings_request(&request)?;
4990 resolve_request_model(
4991 &state.served_model_registry,
4992 &request.model,
4993 ServedModelKind::Embedding,
4994 )?;
4995
4996 let items: Vec<EmbeddingItem> = match request.input {
4998 EmbeddingInput::Single(text) => vec![EmbeddingItem {
4999 text: Some(text),
5000 image: None,
5001 }],
5002 EmbeddingInput::Batch(texts) => texts
5003 .into_iter()
5004 .map(|t| EmbeddingItem {
5005 text: Some(t),
5006 image: None,
5007 })
5008 .collect(),
5009 EmbeddingInput::SingleObject(item) => vec![item],
5010 EmbeddingInput::BatchObjects(items) => items,
5011 };
5012
5013 if items.is_empty() {
5014 return Err(ServerError::invalid_request(
5015 "input must not be empty",
5016 Some("input"),
5017 ));
5018 }
5019
5020 let mut data = Vec::with_capacity(items.len());
5021 let mut total_tokens = 0u32;
5022
5023 let engine = state.embed.as_ref().ok_or_else(|| {
5024 ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
5025 })?;
5026 for (idx, item) in items.iter().enumerate() {
5027 let embedding = if let Some(ref image) = item.image {
5028 engine
5029 .embed_image(image)
5030 .await
5031 .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
5032 } else if let Some(ref text) = item.text {
5033 total_tokens += text.len() as u32;
5034 engine
5035 .embed_text(text)
5036 .await
5037 .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
5038 } else {
5039 return Err(ServerError::invalid_request(
5040 "each input item must have either text or image",
5041 Some("input"),
5042 ));
5043 };
5044
5045 data.push(EmbeddingData {
5046 object: "embedding".to_string(),
5047 embedding,
5048 index: idx,
5049 });
5050 }
5051
5052 let response = EmbeddingsResponse {
5053 object: "list".to_string(),
5054 data,
5055 model: request.model,
5056 usage: EmbeddingUsage {
5057 prompt_tokens: total_tokens,
5058 total_tokens,
5059 },
5060 };
5061
5062 Ok(Json(response).into_response())
5063}
5064
5065fn validate_embeddings_request(
5066 request: &EmbeddingsRequest,
5067) -> std::result::Result<(), ServerError> {
5068 if let Some(format) = request.encoding_format.as_deref() {
5069 if !format.eq_ignore_ascii_case("float") {
5070 return Err(ServerError::unsupported_feature(
5071 "only encoding_format=float is supported for embeddings",
5072 Some("encoding_format"),
5073 ));
5074 }
5075 }
5076 Ok(())
5077}
5078
5079async fn transcriptions_handler(
5081 State(state): State<AppState>,
5082 multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
5083) -> std::result::Result<Response, ServerError> {
5084 let mut multipart = multipart.map_err(|e| {
5085 ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
5086 })?;
5087
5088 let span = span!(Level::INFO, "transcription");
5089 let _enter = span.enter();
5090
5091 let mut file_data: Option<Vec<u8>> = None;
5092 let mut language: Option<String> = None;
5093 let mut response_format: Option<String> = None;
5094
5095 while let Some(field) = multipart
5096 .next_field()
5097 .await
5098 .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
5099 {
5100 let name = field.name().unwrap_or("").to_string();
5101 match name.as_str() {
5102 "file" => {
5103 file_data = Some(
5104 field
5105 .bytes()
5106 .await
5107 .map_err(|e| {
5108 ServerError::invalid_request(format!("read file: {e}"), Some("file"))
5109 })?
5110 .to_vec(),
5111 );
5112 }
5113 "language" => {
5114 language = field.text().await.ok().filter(|s| !s.is_empty());
5115 }
5116 "response_format" => {
5117 response_format = field.text().await.ok().filter(|s| !s.is_empty());
5118 }
5119 _ => {} }
5121 }
5122
5123 validate_transcription_response_format(response_format.as_deref())?;
5124
5125 let data = file_data
5126 .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
5127
5128 let engine = state.transcribe.as_ref().ok_or_else(|| {
5129 ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
5130 })?;
5131 let text = engine
5132 .transcribe_bytes(&data, language.as_deref())
5133 .await
5134 .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
5135
5136 Ok(Json(TranscriptionResponse { text }).into_response())
5137}
5138
5139fn validate_transcription_response_format(
5140 response_format: Option<&str>,
5141) -> std::result::Result<(), ServerError> {
5142 if let Some(format) = response_format {
5143 if !format.eq_ignore_ascii_case("json") {
5144 return Err(ServerError::unsupported_feature(
5145 "only response_format=json is supported for transcriptions",
5146 Some("response_format"),
5147 ));
5148 }
5149 }
5150 Ok(())
5151}
5152
5153async fn speech_handler(
5155 State(state): State<AppState>,
5156 request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
5157) -> std::result::Result<Response, ServerError> {
5158 let Json(request) = request
5159 .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
5160
5161 let response_format = speech_output_format(&request)?;
5162 resolve_request_model(
5163 &state.served_model_registry,
5164 &request.model,
5165 ServedModelKind::Speech,
5166 )?;
5167
5168 let span = span!(Level::INFO, "speech");
5169 let _guard = span.enter();
5170
5171 let language = if request.language.is_empty() || request.language == "auto" {
5172 None
5173 } else {
5174 Some(request.language.as_str())
5175 };
5176
5177 let chunk_frames = 10usize;
5178 let tts = state.tts.as_ref().ok_or_else(|| {
5179 ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
5180 })?;
5181 let sample_rate = tts.tts_sample_rate();
5182
5183 if request.stream {
5184 let (tx, rx) =
5186 mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
5187
5188 let engine = tts.clone();
5189 let text = request.input.clone();
5190 let lang = request.language.clone();
5191
5192 tokio::task::spawn_blocking(move || {
5193 let lang_opt = if lang.is_empty() || lang == "auto" {
5194 None
5195 } else {
5196 Some(lang.as_str())
5197 };
5198 let rt = tokio::runtime::Handle::current();
5199
5200 match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
5201 Ok(chunks) => {
5202 for chunk in &chunks {
5203 let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
5204 let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
5205 }
5206 }
5207 Err(e) => {
5208 error!("TTS error: {e}");
5209 }
5210 }
5211 });
5212
5213 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
5214 let body = axum::body::Body::from_stream(stream);
5215 Ok(Response::builder()
5216 .status(200)
5217 .header("content-type", speech_content_type(response_format))
5218 .header("transfer-encoding", "chunked")
5219 .body(body)
5220 .unwrap())
5221 } else {
5222 let chunks = tts
5224 .synthesize_speech(&request.input, language, chunk_frames)
5225 .await
5226 .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
5227
5228 let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
5229 let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
5230
5231 Ok(Response::builder()
5232 .status(200)
5233 .header("content-type", speech_content_type(response_format))
5234 .header("content-length", audio_bytes.len().to_string())
5235 .body(axum::body::Body::from(audio_bytes))
5236 .unwrap())
5237 }
5238}
5239
5240#[derive(Clone, Copy)]
5241enum SpeechOutputFormat {
5242 Wav,
5243 Pcm,
5244}
5245
5246fn speech_output_format(
5247 request: &SpeechRequest,
5248) -> std::result::Result<SpeechOutputFormat, ServerError> {
5249 if request.response_format.eq_ignore_ascii_case("wav") {
5250 Ok(SpeechOutputFormat::Wav)
5251 } else if request.response_format.eq_ignore_ascii_case("pcm") {
5252 Ok(SpeechOutputFormat::Pcm)
5253 } else {
5254 Err(ServerError::unsupported_feature(
5255 "only response_format=wav or response_format=pcm is supported for speech",
5256 Some("response_format"),
5257 ))
5258 }
5259}
5260
5261fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
5262 match format {
5263 SpeechOutputFormat::Wav => "audio/wav",
5264 SpeechOutputFormat::Pcm => "audio/pcm",
5265 }
5266}
5267
5268fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
5269 match format {
5270 SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
5271 SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
5272 }
5273}
5274
5275fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
5277 let num_samples = samples.len();
5278 let data_size = num_samples * 2; let file_size = 44 + data_size;
5280
5281 let mut buf = Vec::with_capacity(file_size);
5282 buf.extend_from_slice(b"RIFF");
5284 buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
5285 buf.extend_from_slice(b"WAVE");
5286 buf.extend_from_slice(b"fmt ");
5288 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());
5292 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");
5297 buf.extend_from_slice(&(data_size as u32).to_le_bytes());
5298 buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
5299 buf
5300}
5301
5302fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
5303 let mut buf = Vec::with_capacity(samples.len() * 2);
5304 for &s in samples {
5305 let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
5306 buf.extend_from_slice(&i16_val.to_le_bytes());
5307 }
5308 buf
5309}
5310
5311async fn models_handler(
5312 State(state): State<AppState>,
5313) -> std::result::Result<Response, ServerError> {
5314 let now = chrono::Utc::now().timestamp() as u64;
5315 let data = state
5316 .served_model_registry
5317 .entries()
5318 .iter()
5319 .map(|entry| crate::openai::ModelInfo {
5320 id: entry.public_name().to_string(),
5321 object: "model".to_string(),
5322 created: now,
5323 owned_by: "ferrum".to_string(),
5324 modalities: entry
5325 .kind()
5326 .modalities()
5327 .iter()
5328 .map(ToString::to_string)
5329 .collect(),
5330 permission: vec![],
5331 root: entry.parent_public_name().map(ToString::to_string),
5332 parent: entry.parent_public_name().map(ToString::to_string),
5333 })
5334 .collect();
5335
5336 let models = ModelListResponse {
5337 object: "list".to_string(),
5338 data,
5339 };
5340
5341 Ok(Json(models).into_response())
5342}
5343
5344async fn health_handler(
5345 State(state): State<AppState>,
5346) -> std::result::Result<Response, ServerError> {
5347 let engine_status = state.status().await;
5348 let scheduler_metrics = state.metrics();
5349 let runtime_config = RuntimeConfigSnapshot::capture_current();
5350 let cache_policy = CachePolicy::current();
5351 let engine_cache = state
5352 .llm
5353 .as_ref()
5354 .and_then(|engine| engine.cache_metrics_snapshot());
5355 let execution_attribution = state
5356 .llm
5357 .as_ref()
5358 .and_then(|engine| engine.execution_attribution_snapshot());
5359 let engine_lora = state
5360 .llm
5361 .as_ref()
5362 .and_then(|engine| engine.lora_metrics_snapshot());
5363 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5364 let runtime_admission = match state.llm.as_ref() {
5365 Some(engine) => engine.admission_snapshot(),
5366 None => Ok(None),
5367 };
5368 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5369 Ok(snapshot) => (snapshot.as_ref(), None),
5370 Err(error) => (None, Some(error.to_string())),
5371 };
5372 let admission = admission_health_json(
5373 &engine_status,
5374 &scheduler_metrics,
5375 &auto_config,
5376 runtime_admission_snapshot,
5377 runtime_admission_error.as_deref(),
5378 );
5379
5380 let health = serde_json::json!({
5381 "status": if runtime_admission_error.is_some() { "unhealthy" } else { "healthy" },
5382 "reasoning_protocol": state.prompt_template.as_deref().map(ModelChatTemplate::reasoning_capability).unwrap_or_default(),
5383 "timestamp": chrono::Utc::now().to_rfc3339(),
5384 "version": env!("CARGO_PKG_VERSION"),
5385 "engine": {
5386 "active_requests": engine_status.active_requests,
5387 "queued_requests": engine_status.queued_requests,
5388 },
5389 "scheduler": {
5390 "total_requests": scheduler_metrics.total_requests,
5391 "successful_requests": scheduler_metrics.successful_requests,
5392 "failed_requests": scheduler_metrics.failed_requests,
5393 "throughput_rps": scheduler_metrics.throughput_rps,
5394 "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
5395 "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
5396 "model_execution_time_ms": scheduler_metrics
5397 .performance_breakdown
5398 .model_execution_time_ms,
5399 "iteration_lock_wait_time_ms": scheduler_metrics
5400 .performance_breakdown
5401 .other_overhead_time_ms,
5402 },
5403 "config": runtime_config,
5404 "auto_config": auto_config,
5405 "admission": admission,
5406 "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
5407 "execution_attribution": execution_attribution,
5408 "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
5409 "enabled": state.served_model_registry.adapter_count() > 0,
5410 "adapter_count": state.served_model_registry.adapter_count() as u64,
5411 "active_cache_bindings": 0u64,
5412 "projection_applications": 0u64,
5413 "position": "startup-routing",
5414 "source": "server-lora-registry",
5415 })),
5416 });
5417
5418 Ok(Json(health).into_response())
5419}
5420
5421async fn metrics_handler(
5423 State(state): State<AppState>,
5424) -> std::result::Result<Response, ServerError> {
5425 let mut body = match PROM_HANDLE.get() {
5426 Some(handle) => handle.render(),
5427 None => "# Prometheus recorder not initialized\n".to_string(),
5428 };
5429 if !body.ends_with('\n') {
5430 body.push('\n');
5431 }
5432 let engine_cache = state
5433 .llm
5434 .as_ref()
5435 .and_then(|engine| engine.cache_metrics_snapshot());
5436 body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
5437 let engine_status = state.status().await;
5438 let scheduler_metrics = state.metrics();
5439 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5440 let runtime_admission = match state.llm.as_ref() {
5441 Some(engine) => engine.admission_snapshot(),
5442 None => Ok(None),
5443 };
5444 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5445 Ok(snapshot) => (snapshot.as_ref(), None),
5446 Err(error) => (None, Some(error.to_string())),
5447 };
5448 let admission = admission_health_json(
5449 &engine_status,
5450 &scheduler_metrics,
5451 &auto_config,
5452 runtime_admission_snapshot,
5453 runtime_admission_error.as_deref(),
5454 );
5455 body.push_str(&admission_prometheus_metrics(&admission));
5456
5457 Ok((
5458 [(
5459 axum::http::header::CONTENT_TYPE,
5460 "text/plain; version=0.0.4; charset=utf-8",
5461 )],
5462 body,
5463 )
5464 .into_response())
5465}
5466
5467async fn root_handler() -> std::result::Result<Response, ServerError> {
5468 let info = serde_json::json!({
5469 "name": "Ferrum Inference Server",
5470 "version": env!("CARGO_PKG_VERSION"),
5471 "api_version": "v1",
5472 "status": "running"
5473 });
5474
5475 Ok(Json(info).into_response())
5476}
5477
5478#[derive(Debug)]
5480enum ServerError {
5481 InvalidRequest {
5482 message: String,
5483 param: Option<String>,
5484 },
5485 UnsupportedFeature {
5486 message: String,
5487 param: Option<String>,
5488 },
5489 InternalError(String),
5490 NotImplemented(String),
5491 ServiceUnavailable(String),
5492}
5493
5494impl ServerError {
5495 fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
5496 Self::InvalidRequest {
5497 message: message.into(),
5498 param: param.map(str::to_string),
5499 }
5500 }
5501
5502 fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
5503 Self::UnsupportedFeature {
5504 message: message.into(),
5505 param: param.map(str::to_string),
5506 }
5507 }
5508}
5509
5510impl IntoResponse for ServerError {
5511 fn into_response(self) -> Response {
5512 let (status, message, error_type, param) = match self {
5513 ServerError::InvalidRequest { message, param } => (
5514 AxumStatusCode::BAD_REQUEST,
5515 message,
5516 "invalid_request_error",
5517 param,
5518 ),
5519 ServerError::UnsupportedFeature { message, param } => (
5520 AxumStatusCode::BAD_REQUEST,
5521 message,
5522 "invalid_request_error",
5523 param,
5524 ),
5525 ServerError::InternalError(msg) => (
5526 AxumStatusCode::INTERNAL_SERVER_ERROR,
5527 msg,
5528 "internal_server_error",
5529 None,
5530 ),
5531 ServerError::NotImplemented(msg) => (
5532 AxumStatusCode::SERVICE_UNAVAILABLE,
5533 msg,
5534 "service_unavailable_error",
5535 None,
5536 ),
5537 ServerError::ServiceUnavailable(msg) => (
5538 AxumStatusCode::SERVICE_UNAVAILABLE,
5539 msg,
5540 "service_unavailable_error",
5541 None,
5542 ),
5543 };
5544
5545 let error = OpenAiError {
5546 error: OpenAiErrorDetail {
5547 message,
5548 error_type: error_type.to_string(),
5549 param,
5550 code: None,
5551 },
5552 };
5553
5554 (status, Json(error)).into_response()
5555 }
5556}
5557
5558impl std::fmt::Display for MessageRole {
5559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5560 match self {
5561 MessageRole::System => write!(f, "system"),
5562 MessageRole::User => write!(f, "user"),
5563 MessageRole::Assistant => write!(f, "assistant"),
5564 MessageRole::Function => write!(f, "function"),
5565 MessageRole::Tool => write!(f, "tool"),
5566 }
5567 }
5568}
5569
5570fn strip_after_stop(text: &str, stops: &[String]) -> String {
5574 let mut first: Option<usize> = None;
5575 for stop in stops {
5576 if stop.is_empty() {
5577 continue;
5578 }
5579 if let Some(idx) = text.find(stop.as_str()) {
5580 first = Some(first.map_or(idx, |current| current.min(idx)));
5581 }
5582 }
5583 match first {
5584 Some(idx) => text[..idx].to_string(),
5585 None => text.to_string(),
5586 }
5587}
5588
5589fn strip_markdown_json_fence(text: &str) -> String {
5592 let trimmed = text.trim();
5593 for prefix in ["```json\n", "```json", "```\n", "```"] {
5595 if let Some(rest) = trimmed.strip_prefix(prefix) {
5596 if let Some(inner) = rest.strip_suffix("```") {
5597 return inner.trim().to_string();
5598 }
5599 }
5600 }
5601 text.to_string()
5602}
5603
5604fn finish_reason_to_string(reason: &FinishReason) -> String {
5606 match reason {
5607 FinishReason::Length => "length".to_string(),
5608 FinishReason::Stop => "stop".to_string(),
5609 FinishReason::EOS => "stop".to_string(),
5610 FinishReason::Cancelled => "cancelled".to_string(),
5611 FinishReason::Error => "error".to_string(),
5612 FinishReason::ContentFilter => "content_filter".to_string(),
5613 }
5614}
5615
5616#[cfg(test)]
5617mod tests {
5618 mod engine_stop_contract;
5619 mod gemma_thought;
5620 mod harmony_stops;
5621 use super::*;
5622 use async_trait::async_trait;
5623 use axum::{
5624 body::{to_bytes, Body},
5625 http::{header, Request},
5626 response::Response,
5627 };
5628 use ferrum_interfaces::engine::{
5629 EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
5630 };
5631 use ferrum_types::{
5632 has_unclosed_thinking_block, parse_reasoning_response_started_in_think, EngineConfig,
5633 EngineMetrics, EngineStatus, EngineTokenTimingEvidence, FinishReason,
5634 HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
5635 ModelId, StreamChunk, TokenId, TokenUsage,
5636 };
5637 use futures::{stream, Stream};
5638 use serde_json::{json, Value};
5639 use std::{
5640 collections::HashMap,
5641 pin::Pin,
5642 sync::{atomic::AtomicUsize, Arc, Mutex},
5643 };
5644 use tower::ServiceExt;
5645
5646 #[test]
5647 fn strip_after_stop_removes_first_boundary() {
5648 assert_eq!(
5649 strip_after_stop(
5650 "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
5651 &["END0214Z".to_string()]
5652 ),
5653 "KS0214Z\nS0225\n"
5654 );
5655 }
5656
5657 #[test]
5658 fn gpt_oss_harmony_final_is_split_into_reasoning_and_visible_content() {
5659 let parsed = parse_chat_model_output(
5660 ModelOutputProtocol::HarmonyGptOss,
5661 "<|channel|>analysis<|message|>Reason.<|end|>\
5662 <|start|>assistant<|channel|>final<|message|>Answer.<|return|>",
5663 false,
5664 FinishReason::Stop,
5665 )
5666 .unwrap();
5667 assert_eq!(parsed.visible.content, "Answer.");
5668 assert_eq!(parsed.visible.reasoning.as_deref(), Some("Reason."));
5669 assert!(parsed.harmony_response.is_none());
5670 }
5671
5672 #[test]
5673 fn gpt_oss_harmony_tool_call_becomes_openai_structured_response() {
5674 let parsed = parse_chat_model_output(
5675 ModelOutputProtocol::HarmonyGptOss,
5676 "<|channel|>analysis<|message|>Need weather.<|end|>\
5677 <|start|>assistant<|channel|>commentary to=functions.weather\
5678 <|constrain|>json<|message|>{\"city\":\"Paris\"}<|call|>",
5679 false,
5680 FinishReason::Stop,
5681 )
5682 .unwrap();
5683 let response = parsed.harmony_response.unwrap();
5684 assert_eq!(response.finish_reason.as_deref(), Some("tool_calls"));
5685 assert_eq!(response.message.tool_calls.len(), 1);
5686 assert_eq!(response.message.tool_calls[0].function.name, "weather");
5687 assert_eq!(
5688 response.message.tool_calls[0].function.arguments,
5689 "{\"city\":\"Paris\"}"
5690 );
5691 assert!(response.message.tool_calls[0].id.starts_with("call_"));
5692 }
5693
5694 #[test]
5695 fn gpt_oss_harmony_accepts_missing_text_terminal_only_for_explicit_truncation() {
5696 let output = "<|channel|>analysis<|message|>Still reasoning";
5697 for finish_reason in [FinishReason::Stop, FinishReason::Length] {
5698 let parsed = parse_chat_model_output(
5699 ModelOutputProtocol::HarmonyGptOss,
5700 output,
5701 false,
5702 finish_reason,
5703 )
5704 .unwrap();
5705 assert_eq!(parsed.visible.reasoning.as_deref(), Some("Still reasoning"));
5706 assert!(parsed.visible.content.is_empty());
5707 }
5708 for finish_reason in [
5709 FinishReason::EOS,
5710 FinishReason::Cancelled,
5711 FinishReason::Error,
5712 FinishReason::ContentFilter,
5713 ] {
5714 assert!(parse_chat_model_output(
5715 ModelOutputProtocol::HarmonyGptOss,
5716 output,
5717 false,
5718 finish_reason,
5719 )
5720 .is_err());
5721 }
5722 }
5723
5724 #[tokio::test]
5725 async fn stop_drains_running_server_and_shuts_down_loaded_engine_once() {
5726 let engine = Arc::new(StubLlm::new("ok"));
5727 let server = Arc::new(AxumServer::from_llm(engine.clone()));
5728 let config = ServerConfig {
5729 host: "127.0.0.1".to_string(),
5730 port: 0,
5731 ..ServerConfig::default()
5732 };
5733 let server_task = {
5734 let server = Arc::clone(&server);
5735 tokio::spawn(async move { server.start(&config).await })
5736 };
5737 tokio::time::timeout(std::time::Duration::from_secs(1), async {
5738 while !server.is_running() {
5739 tokio::task::yield_now().await;
5740 }
5741 })
5742 .await
5743 .unwrap();
5744
5745 server
5746 .stop(std::time::Duration::from_secs(1))
5747 .await
5748 .unwrap();
5749 server
5750 .stop(std::time::Duration::from_secs(1))
5751 .await
5752 .unwrap();
5753 server_task.await.unwrap().unwrap();
5754
5755 assert_eq!(engine.shutdown_count.load(Ordering::Acquire), 1);
5756 assert!(!server.is_running());
5757 }
5758
5759 struct StubLlm {
5760 config: EngineConfig,
5761 text: String,
5762 stream_chunks: Option<Vec<String>>,
5763 stream_final_chunk_separate: bool,
5764 stream_tail_without_token: bool,
5765 stream_usage: Option<TokenUsage>,
5766 api_response: Option<ferrum_types::ApiResponse>,
5767 finish_reason: FinishReason,
5768 execution_attribution: Option<Value>,
5769 lora_metrics: Option<Value>,
5770 pending_stream_drop_notify: Option<Arc<Notify>>,
5771 shutdown_count: AtomicUsize,
5772 }
5773
5774 impl StubLlm {
5775 fn new(text: &str) -> Self {
5776 let mut config = EngineConfig::default();
5777 config.model.model_id = ModelId::new("stub-model");
5778 Self {
5779 config,
5780 text: text.to_string(),
5781 stream_chunks: None,
5782 stream_final_chunk_separate: false,
5783 stream_tail_without_token: false,
5784 stream_usage: Some(TokenUsage::new(5, 1)),
5785 api_response: None,
5786 finish_reason: FinishReason::EOS,
5787 execution_attribution: None,
5788 lora_metrics: None,
5789 pending_stream_drop_notify: None,
5790 shutdown_count: AtomicUsize::new(0),
5791 }
5792 }
5793
5794 fn without_stream_usage(text: &str) -> Self {
5795 Self {
5796 stream_usage: None,
5797 ..Self::new(text)
5798 }
5799 }
5800
5801 fn with_stream_chunks(chunks: &[&str]) -> Self {
5802 Self {
5803 text: chunks.concat(),
5804 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
5805 stream_usage: Some(TokenUsage::new(5, chunks.len())),
5806 ..Self::new("")
5807 }
5808 }
5809
5810 fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
5811 Self {
5812 text: chunks.concat(),
5813 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
5814 stream_final_chunk_separate: true,
5815 stream_usage: Some(TokenUsage::new(5, chunks.len())),
5816 ..Self::new("")
5817 }
5818 }
5819
5820 fn with_tokenless_tail(chunks: &[&str]) -> Self {
5821 Self {
5822 stream_tail_without_token: true,
5823 ..Self::with_separate_final_stream_chunk(chunks)
5824 }
5825 }
5826
5827 fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
5828 Self {
5829 api_response: Some(api_response),
5830 ..Self::new(text)
5831 }
5832 }
5833
5834 fn with_api_response_and_finish_reason(
5835 text: &str,
5836 api_response: ferrum_types::ApiResponse,
5837 finish_reason: FinishReason,
5838 ) -> Self {
5839 Self {
5840 api_response: Some(api_response),
5841 finish_reason,
5842 ..Self::new(text)
5843 }
5844 }
5845
5846 fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
5847 Self {
5848 lora_metrics: Some(lora_metrics),
5849 ..Self::new(text)
5850 }
5851 }
5852
5853 fn with_execution_attribution(text: &str, execution_attribution: Value) -> Self {
5854 Self {
5855 execution_attribution: Some(execution_attribution),
5856 ..Self::new(text)
5857 }
5858 }
5859
5860 fn with_pending_stream(drop_notify: Arc<Notify>) -> Self {
5861 Self {
5862 pending_stream_drop_notify: Some(drop_notify),
5863 ..Self::new("")
5864 }
5865 }
5866 }
5867
5868 struct PendingDropStream {
5869 drop_notify: Arc<Notify>,
5870 }
5871
5872 impl Stream for PendingDropStream {
5873 type Item = ferrum_types::Result<StreamChunk>;
5874
5875 fn poll_next(
5876 self: Pin<&mut Self>,
5877 _cx: &mut std::task::Context<'_>,
5878 ) -> std::task::Poll<Option<Self::Item>> {
5879 std::task::Poll::Pending
5880 }
5881 }
5882
5883 impl Drop for PendingDropStream {
5884 fn drop(&mut self) {
5885 self.drop_notify.notify_one();
5886 }
5887 }
5888
5889 struct StubEmbed {
5890 config: EngineConfig,
5891 }
5892
5893 impl StubEmbed {
5894 fn new() -> Self {
5895 let mut config = EngineConfig::default();
5896 config.model.model_id = ModelId::new("stub-embed");
5897 Self { config }
5898 }
5899 }
5900
5901 struct StubTranscribe {
5902 config: EngineConfig,
5903 }
5904
5905 impl StubTranscribe {
5906 fn new() -> Self {
5907 let mut config = EngineConfig::default();
5908 config.model.model_id = ModelId::new("stub-transcribe");
5909 Self { config }
5910 }
5911 }
5912
5913 struct StubTts {
5914 config: EngineConfig,
5915 }
5916
5917 impl StubTts {
5918 fn new() -> Self {
5919 let mut config = EngineConfig::default();
5920 config.model.model_id = ModelId::new("stub-tts");
5921 Self { config }
5922 }
5923 }
5924
5925 struct FailingLlm {
5926 config: EngineConfig,
5927 fail_after_stream_start: bool,
5928 infer_failure: ferrum_types::FerrumError,
5929 stream_start_failure: ferrum_types::FerrumError,
5930 stream_chunk_failure: ferrum_types::FerrumError,
5931 }
5932
5933 impl FailingLlm {
5934 fn new() -> Self {
5935 let mut config = EngineConfig::default();
5936 config.model.model_id = ModelId::new("failing-model");
5937 Self {
5938 config,
5939 fail_after_stream_start: false,
5940 infer_failure: ferrum_types::FerrumError::internal("stub generation failed"),
5941 stream_start_failure: ferrum_types::FerrumError::internal("stub stream failed"),
5942 stream_chunk_failure: ferrum_types::FerrumError::internal(
5943 "stub stream chunk failed",
5944 ),
5945 }
5946 }
5947
5948 fn after_stream_start() -> Self {
5949 Self {
5950 fail_after_stream_start: true,
5951 ..Self::new()
5952 }
5953 }
5954
5955 fn resource_exhausted() -> Self {
5956 let failure = ferrum_types::FerrumError::resource_exhausted(
5957 "admission capacity exhausted while reserving request resources",
5958 );
5959 Self {
5960 infer_failure: failure.clone(),
5961 stream_start_failure: failure.clone(),
5962 stream_chunk_failure: failure,
5963 ..Self::new()
5964 }
5965 }
5966 }
5967
5968 struct CapturingLlm {
5969 config: EngineConfig,
5970 last_request: Mutex<Option<InferenceRequest>>,
5971 }
5972
5973 impl CapturingLlm {
5974 fn new() -> Self {
5975 let mut config = EngineConfig::default();
5976 config.model.model_id = ModelId::new("qwen3");
5977 Self {
5978 config,
5979 last_request: Mutex::new(None),
5980 }
5981 }
5982
5983 fn last_request(&self) -> InferenceRequest {
5984 self.last_request
5985 .lock()
5986 .expect("capture lock")
5987 .clone()
5988 .expect("request captured")
5989 }
5990
5991 fn has_captured_request(&self) -> bool {
5992 self.last_request.lock().expect("capture lock").is_some()
5993 }
5994 }
5995
5996 #[async_trait]
5997 impl InferenceEngine for StubLlm {
5998 async fn status(&self) -> EngineStatus {
5999 EngineStatus {
6000 is_ready: true,
6001 loaded_models: vec![self.config.model.model_id.clone()],
6002 active_requests: 0,
6003 queued_requests: 0,
6004 memory_usage: MemoryUsage {
6005 total_bytes: 0,
6006 used_bytes: 0,
6007 free_bytes: 0,
6008 gpu_memory_bytes: None,
6009 cpu_memory_bytes: None,
6010 cache_memory_bytes: 0,
6011 utilization_percent: 0.0,
6012 },
6013 uptime_seconds: 0,
6014 last_heartbeat: chrono::Utc::now(),
6015 version: "test".to_string(),
6016 }
6017 }
6018
6019 async fn shutdown(&self) -> ferrum_types::Result<()> {
6020 self.shutdown_count.fetch_add(1, Ordering::AcqRel);
6021 Ok(())
6022 }
6023
6024 fn config(&self) -> &EngineConfig {
6025 &self.config
6026 }
6027
6028 fn metrics(&self) -> EngineMetrics {
6029 EngineMetrics::default()
6030 }
6031
6032 async fn health_check(&self) -> EngineHealthStatus {
6033 EngineHealthStatus::healthy()
6034 }
6035
6036 fn execution_attribution_snapshot(&self) -> Option<Value> {
6037 self.execution_attribution.clone()
6038 }
6039
6040 fn lora_metrics_snapshot(&self) -> Option<Value> {
6041 self.lora_metrics.clone()
6042 }
6043 }
6044
6045 #[async_trait]
6046 impl InferenceEngine for StubEmbed {
6047 async fn status(&self) -> EngineStatus {
6048 EngineStatus {
6049 is_ready: true,
6050 loaded_models: vec![self.config.model.model_id.clone()],
6051 active_requests: 0,
6052 queued_requests: 0,
6053 memory_usage: MemoryUsage {
6054 total_bytes: 0,
6055 used_bytes: 0,
6056 free_bytes: 0,
6057 gpu_memory_bytes: None,
6058 cpu_memory_bytes: None,
6059 cache_memory_bytes: 0,
6060 utilization_percent: 0.0,
6061 },
6062 uptime_seconds: 0,
6063 last_heartbeat: chrono::Utc::now(),
6064 version: "test".to_string(),
6065 }
6066 }
6067
6068 async fn shutdown(&self) -> ferrum_types::Result<()> {
6069 Ok(())
6070 }
6071
6072 fn config(&self) -> &EngineConfig {
6073 &self.config
6074 }
6075
6076 fn metrics(&self) -> EngineMetrics {
6077 EngineMetrics::default()
6078 }
6079
6080 async fn health_check(&self) -> EngineHealthStatus {
6081 EngineHealthStatus::healthy()
6082 }
6083 }
6084
6085 #[async_trait]
6086 impl EmbedEngine for StubEmbed {
6087 async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
6088 Ok(vec![text.len() as f32, 1.0, 0.0])
6089 }
6090
6091 async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
6092 Ok(vec![image.len() as f32, 0.0, 1.0])
6093 }
6094
6095 fn embedding_dim(&self) -> usize {
6096 3
6097 }
6098 }
6099
6100 #[async_trait]
6101 impl InferenceEngine for StubTranscribe {
6102 async fn status(&self) -> EngineStatus {
6103 EngineStatus {
6104 is_ready: true,
6105 loaded_models: vec![self.config.model.model_id.clone()],
6106 active_requests: 0,
6107 queued_requests: 0,
6108 memory_usage: MemoryUsage {
6109 total_bytes: 0,
6110 used_bytes: 0,
6111 free_bytes: 0,
6112 gpu_memory_bytes: None,
6113 cpu_memory_bytes: None,
6114 cache_memory_bytes: 0,
6115 utilization_percent: 0.0,
6116 },
6117 uptime_seconds: 0,
6118 last_heartbeat: chrono::Utc::now(),
6119 version: "test".to_string(),
6120 }
6121 }
6122
6123 async fn shutdown(&self) -> ferrum_types::Result<()> {
6124 Ok(())
6125 }
6126
6127 fn config(&self) -> &EngineConfig {
6128 &self.config
6129 }
6130
6131 fn metrics(&self) -> EngineMetrics {
6132 EngineMetrics::default()
6133 }
6134
6135 async fn health_check(&self) -> EngineHealthStatus {
6136 EngineHealthStatus::healthy()
6137 }
6138 }
6139
6140 #[async_trait]
6141 impl TranscribeEngine for StubTranscribe {
6142 async fn transcribe_file(
6143 &self,
6144 path: &str,
6145 language: Option<&str>,
6146 ) -> ferrum_types::Result<String> {
6147 Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
6148 }
6149
6150 async fn transcribe_bytes(
6151 &self,
6152 data: &[u8],
6153 language: Option<&str>,
6154 ) -> ferrum_types::Result<String> {
6155 Ok(format!(
6156 "bytes:{}:{}",
6157 data.len(),
6158 language.unwrap_or("auto")
6159 ))
6160 }
6161 }
6162
6163 #[async_trait]
6164 impl InferenceEngine for StubTts {
6165 async fn status(&self) -> EngineStatus {
6166 EngineStatus {
6167 is_ready: true,
6168 loaded_models: vec![self.config.model.model_id.clone()],
6169 active_requests: 0,
6170 queued_requests: 0,
6171 memory_usage: MemoryUsage {
6172 total_bytes: 0,
6173 used_bytes: 0,
6174 free_bytes: 0,
6175 gpu_memory_bytes: None,
6176 cpu_memory_bytes: None,
6177 cache_memory_bytes: 0,
6178 utilization_percent: 0.0,
6179 },
6180 uptime_seconds: 0,
6181 last_heartbeat: chrono::Utc::now(),
6182 version: "test".to_string(),
6183 }
6184 }
6185
6186 async fn shutdown(&self) -> ferrum_types::Result<()> {
6187 Ok(())
6188 }
6189
6190 fn config(&self) -> &EngineConfig {
6191 &self.config
6192 }
6193
6194 fn metrics(&self) -> EngineMetrics {
6195 EngineMetrics::default()
6196 }
6197
6198 async fn health_check(&self) -> EngineHealthStatus {
6199 EngineHealthStatus::healthy()
6200 }
6201 }
6202
6203 #[async_trait]
6204 impl TtsEngine for StubTts {
6205 async fn synthesize_speech(
6206 &self,
6207 _text: &str,
6208 _language: Option<&str>,
6209 _chunk_frames: usize,
6210 ) -> ferrum_types::Result<Vec<Vec<f32>>> {
6211 Ok(vec![vec![0.0, 0.5, -0.5]])
6212 }
6213
6214 fn tts_sample_rate(&self) -> u32 {
6215 16_000
6216 }
6217 }
6218
6219 #[async_trait]
6220 impl InferenceEngine for FailingLlm {
6221 async fn status(&self) -> EngineStatus {
6222 EngineStatus {
6223 is_ready: true,
6224 loaded_models: vec![self.config.model.model_id.clone()],
6225 active_requests: 0,
6226 queued_requests: 0,
6227 memory_usage: MemoryUsage {
6228 total_bytes: 0,
6229 used_bytes: 0,
6230 free_bytes: 0,
6231 gpu_memory_bytes: None,
6232 cpu_memory_bytes: None,
6233 cache_memory_bytes: 0,
6234 utilization_percent: 0.0,
6235 },
6236 uptime_seconds: 0,
6237 last_heartbeat: chrono::Utc::now(),
6238 version: "test".to_string(),
6239 }
6240 }
6241
6242 async fn shutdown(&self) -> ferrum_types::Result<()> {
6243 Ok(())
6244 }
6245
6246 fn config(&self) -> &EngineConfig {
6247 &self.config
6248 }
6249
6250 fn metrics(&self) -> EngineMetrics {
6251 EngineMetrics::default()
6252 }
6253
6254 async fn health_check(&self) -> EngineHealthStatus {
6255 EngineHealthStatus::healthy()
6256 }
6257 }
6258
6259 #[async_trait]
6260 impl InferenceEngine for CapturingLlm {
6261 async fn status(&self) -> EngineStatus {
6262 EngineStatus {
6263 is_ready: true,
6264 loaded_models: vec![self.config.model.model_id.clone()],
6265 active_requests: 0,
6266 queued_requests: 0,
6267 memory_usage: MemoryUsage {
6268 total_bytes: 0,
6269 used_bytes: 0,
6270 free_bytes: 0,
6271 gpu_memory_bytes: None,
6272 cpu_memory_bytes: None,
6273 cache_memory_bytes: 0,
6274 utilization_percent: 0.0,
6275 },
6276 uptime_seconds: 0,
6277 last_heartbeat: chrono::Utc::now(),
6278 version: "test".to_string(),
6279 }
6280 }
6281
6282 async fn shutdown(&self) -> ferrum_types::Result<()> {
6283 Ok(())
6284 }
6285
6286 fn config(&self) -> &EngineConfig {
6287 &self.config
6288 }
6289
6290 fn metrics(&self) -> EngineMetrics {
6291 EngineMetrics::default()
6292 }
6293
6294 async fn health_check(&self) -> EngineHealthStatus {
6295 EngineHealthStatus::healthy()
6296 }
6297 }
6298
6299 fn stub_execution_evidence(
6300 request: &InferenceRequest,
6301 output_token_count: usize,
6302 ) -> Option<InferenceExecutionEvidence> {
6303 let requested = &request.evidence_request;
6304 if !requested.capture_prompt_token_ids && !requested.capture_engine_token_timing {
6305 return None;
6306 }
6307 Some(InferenceExecutionEvidence {
6308 prompt_token_ids: requested
6309 .capture_prompt_token_ids
6310 .then(|| vec![TokenId::new(101), TokenId::new(202), TokenId::new(303)])
6311 .unwrap_or_default(),
6312 output_token_ids: (0..output_token_count)
6313 .map(|index| TokenId::new(11 + index as u32))
6314 .collect(),
6315 engine_token_timing: requested.capture_engine_token_timing.then(|| {
6316 EngineTokenTimingEvidence {
6317 clock_source: "rust_std_instant".to_string(),
6318 wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
6319 wall_anchor_max_error_nanos: 500,
6320 decode_ready_nanos_since_request_start: Some(1_000_000),
6321 token_commit_nanos_since_request_start: (1..=output_token_count)
6322 .map(|ordinal| ordinal as u64 * 1_000_000)
6323 .collect(),
6324 decode_stage_intervals: Vec::new(),
6325 }
6326 }),
6327 })
6328 }
6329
6330 #[async_trait]
6331 impl LlmInferenceEngine for StubLlm {
6332 async fn infer(
6333 &self,
6334 request: InferenceRequest,
6335 ) -> ferrum_types::Result<InferenceResponse> {
6336 let execution_evidence = stub_execution_evidence(&request, 2);
6337 Ok(InferenceResponse {
6338 request_id: request.id,
6339 text: self.text.clone(),
6340 tokens: vec![TokenId::new(11), TokenId::new(12)],
6341 finish_reason: self.finish_reason,
6342 usage: TokenUsage::new(7, 2),
6343 latency_ms: 1,
6344 created_at: chrono::Utc::now(),
6345 metadata: HashMap::new(),
6346 api_response: self.api_response.clone(),
6347 execution_evidence,
6348 })
6349 }
6350
6351 async fn infer_stream(
6352 &self,
6353 request: InferenceRequest,
6354 ) -> ferrum_types::Result<
6355 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6356 > {
6357 if let Some(drop_notify) = self.pending_stream_drop_notify.as_ref() {
6358 return Ok(Box::pin(PendingDropStream {
6359 drop_notify: Arc::clone(drop_notify),
6360 }));
6361 }
6362 if let Some(chunks) = &self.stream_chunks {
6363 let completion_token_count = self
6364 .stream_usage
6365 .as_ref()
6366 .map(|usage| usage.completion_tokens)
6367 .unwrap_or(chunks.len());
6368 let execution_evidence = stub_execution_evidence(&request, completion_token_count);
6369 let request_id = request.id;
6370 let mut stream_chunks = Vec::with_capacity(
6371 chunks.len() + usize::from(self.stream_final_chunk_separate),
6372 );
6373 let last = chunks.len().saturating_sub(1);
6374 for (index, text) in chunks.iter().enumerate() {
6375 let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
6376 stream_chunks.push(Ok(StreamChunk {
6377 request_id: request_id.clone(),
6378 text: text.clone(),
6379 token: (!(self.stream_tail_without_token && index == last))
6380 .then_some(TokenId::new(11 + index as u32)),
6381 finish_reason: is_final_text_chunk.then_some(self.finish_reason),
6382 usage: is_final_text_chunk
6383 .then(|| self.stream_usage.clone())
6384 .flatten(),
6385 created_at: chrono::Utc::now(),
6386 metadata: HashMap::new(),
6387 api_response: is_final_text_chunk
6388 .then(|| self.api_response.clone())
6389 .flatten(),
6390 execution_evidence: is_final_text_chunk
6391 .then(|| execution_evidence.clone())
6392 .flatten(),
6393 }));
6394 }
6395 if self.stream_final_chunk_separate {
6396 stream_chunks.push(Ok(StreamChunk {
6397 request_id,
6398 text: String::new(),
6399 token: None,
6400 finish_reason: Some(self.finish_reason),
6401 usage: self.stream_usage.clone(),
6402 created_at: chrono::Utc::now(),
6403 metadata: HashMap::new(),
6404 api_response: self.api_response.clone(),
6405 execution_evidence,
6406 }));
6407 }
6408 return Ok(Box::pin(stream::iter(stream_chunks)));
6409 }
6410
6411 let execution_evidence = stub_execution_evidence(&request, 1);
6412 let chunk = StreamChunk {
6413 request_id: request.id,
6414 text: self.text.clone(),
6415 token: Some(TokenId::new(11)),
6416 finish_reason: Some(self.finish_reason),
6417 usage: self.stream_usage.clone(),
6418 created_at: chrono::Utc::now(),
6419 metadata: HashMap::new(),
6420 api_response: self.api_response.clone(),
6421 execution_evidence,
6422 };
6423 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6424 }
6425 }
6426
6427 #[async_trait]
6428 impl LlmInferenceEngine for FailingLlm {
6429 async fn infer(
6430 &self,
6431 _request: InferenceRequest,
6432 ) -> ferrum_types::Result<InferenceResponse> {
6433 Err(self.infer_failure.clone())
6434 }
6435
6436 async fn infer_stream(
6437 &self,
6438 request: InferenceRequest,
6439 ) -> ferrum_types::Result<
6440 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6441 > {
6442 if self.fail_after_stream_start {
6443 let _request_id = request.id;
6444 return Ok(Box::pin(stream::iter(vec![Err(self
6445 .stream_chunk_failure
6446 .clone())])));
6447 }
6448 Err(self.stream_start_failure.clone())
6449 }
6450 }
6451
6452 #[async_trait]
6453 impl LlmInferenceEngine for CapturingLlm {
6454 async fn infer(
6455 &self,
6456 request: InferenceRequest,
6457 ) -> ferrum_types::Result<InferenceResponse> {
6458 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6459 Ok(InferenceResponse {
6460 request_id: request.id,
6461 text: "captured".to_string(),
6462 tokens: vec![TokenId::new(21)],
6463 finish_reason: FinishReason::Stop,
6464 usage: TokenUsage::new(9, 1),
6465 latency_ms: 1,
6466 created_at: chrono::Utc::now(),
6467 metadata: HashMap::new(),
6468 api_response: None,
6469 execution_evidence: None,
6470 })
6471 }
6472
6473 async fn infer_stream(
6474 &self,
6475 request: InferenceRequest,
6476 ) -> ferrum_types::Result<
6477 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6478 > {
6479 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6480 let chunk = StreamChunk {
6481 request_id: request.id,
6482 text: "captured".to_string(),
6483 token: Some(TokenId::new(21)),
6484 finish_reason: Some(FinishReason::Stop),
6485 usage: Some(TokenUsage::new(9, 1)),
6486 created_at: chrono::Utc::now(),
6487 metadata: HashMap::new(),
6488 api_response: None,
6489 execution_evidence: None,
6490 };
6491 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6492 }
6493 }
6494
6495 fn state_with_stub(text: &str) -> AppState {
6496 AppState::default().with_llm(Arc::new(StubLlm::new(text)))
6497 }
6498
6499 fn router_with_stub(text: &str) -> Router {
6500 AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
6501 }
6502
6503 fn router_with_stub_and_template(text: &str, template: ModelChatTemplate) -> Router {
6504 AxumServer::from_llm(Arc::new(StubLlm::new(text)))
6505 .with_prompt_template(Some(template))
6506 .build_router()
6507 }
6508
6509 fn router_with_stub_and_request_dump_dir(text: &str, request_dump_dir: PathBuf) -> Router {
6510 AxumServer::from_state(
6511 AppState::default()
6512 .with_llm(Arc::new(StubLlm::new(text)))
6513 .with_request_dump_dir(Some(request_dump_dir)),
6514 )
6515 .build_router()
6516 }
6517
6518 fn router_with_stub_request_dump_and_profile(
6519 text: &str,
6520 request_dump_dir: PathBuf,
6521 profile_jsonl: PathBuf,
6522 ) -> Router {
6523 AxumServer::from_state(
6524 AppState::default()
6525 .with_llm(Arc::new(StubLlm::new(text)))
6526 .with_request_dump_dir(Some(request_dump_dir))
6527 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6528 .with_profile_jsonl(Some(profile_jsonl)),
6529 )
6530 .build_router()
6531 }
6532
6533 fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
6534 AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
6535 }
6536
6537 fn router_with_stub_finish_reason(text: &str, finish_reason: FinishReason) -> Router {
6538 AxumServer::from_llm(Arc::new(StubLlm {
6539 finish_reason,
6540 ..StubLlm::new(text)
6541 }))
6542 .build_router()
6543 }
6544
6545 fn router_with_stub_stream_chunks_and_request_dump_dir(
6546 chunks: &[&str],
6547 request_dump_dir: PathBuf,
6548 ) -> Router {
6549 AxumServer::from_state(
6550 AppState::default()
6551 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6552 .with_request_dump_dir(Some(request_dump_dir)),
6553 )
6554 .build_router()
6555 }
6556
6557 fn router_with_stub_stream_request_dump_and_profile(
6558 chunks: &[&str],
6559 request_dump_dir: PathBuf,
6560 profile_jsonl: PathBuf,
6561 ) -> Router {
6562 AxumServer::from_state(
6563 AppState::default()
6564 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6565 .with_request_dump_dir(Some(request_dump_dir))
6566 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6567 .with_profile_jsonl(Some(profile_jsonl)),
6568 )
6569 .build_router()
6570 }
6571
6572 fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
6573 AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
6574 .build_router()
6575 }
6576
6577 fn router_with_stub_api_response(
6578 text: &str,
6579 api_response: ferrum_types::ApiResponse,
6580 ) -> Router {
6581 AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
6582 .build_router()
6583 }
6584
6585 fn router_with_stub_api_response_and_finish_reason(
6586 text: &str,
6587 api_response: ferrum_types::ApiResponse,
6588 finish_reason: FinishReason,
6589 ) -> Router {
6590 AxumServer::from_llm(Arc::new(StubLlm::with_api_response_and_finish_reason(
6591 text,
6592 api_response,
6593 finish_reason,
6594 )))
6595 .build_router()
6596 }
6597
6598 fn weather_tool_api_response() -> ferrum_types::ApiResponse {
6599 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6600 message: ferrum_types::ApiChatMessage {
6601 role: ferrum_types::ApiMessageRole::Assistant,
6602 content: String::new(),
6603 name: None,
6604 tool_calls: vec![ferrum_types::ApiToolCall {
6605 id: "call_1".to_string(),
6606 tool_type: "function".to_string(),
6607 function: ferrum_types::ApiFunctionCall {
6608 name: "weather".to_string(),
6609 arguments: "{\"city\":\"Paris\"}".to_string(),
6610 },
6611 }],
6612 tool_call_id: None,
6613 function_call: None,
6614 },
6615 finish_reason: Some("tool_calls".to_string()),
6616 })
6617 }
6618
6619 fn weather_tool_api_response_with_commentary() -> ferrum_types::ApiResponse {
6620 let mut response = weather_tool_api_response();
6621 let ferrum_types::ApiResponse::Chat(chat) = &mut response else {
6622 unreachable!("weather response is chat")
6623 };
6624 chat.message.content = "I will check.".to_string();
6625 response
6626 }
6627
6628 fn namespaced_tool_api_response() -> ferrum_types::ApiResponse {
6629 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6630 message: ferrum_types::ApiChatMessage {
6631 role: ferrum_types::ApiMessageRole::Assistant,
6632 content: String::new(),
6633 name: None,
6634 tool_calls: vec![ferrum_types::ApiToolCall {
6635 id: "call_ns_1".to_string(),
6636 tool_type: "function".to_string(),
6637 function: ferrum_types::ApiFunctionCall {
6638 name: "collaboration__wait_agent".to_string(),
6639 arguments: "{}".to_string(),
6640 },
6641 }],
6642 tool_call_id: None,
6643 function_call: None,
6644 },
6645 finish_reason: Some("tool_calls".to_string()),
6646 })
6647 }
6648
6649 fn two_tool_api_response() -> ferrum_types::ApiResponse {
6650 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6651 message: ferrum_types::ApiChatMessage {
6652 role: ferrum_types::ApiMessageRole::Assistant,
6653 content: String::new(),
6654 name: None,
6655 tool_calls: vec![
6656 ferrum_types::ApiToolCall {
6657 id: "call_1".to_string(),
6658 tool_type: "function".to_string(),
6659 function: ferrum_types::ApiFunctionCall {
6660 name: "weather".to_string(),
6661 arguments: "{}".to_string(),
6662 },
6663 },
6664 ferrum_types::ApiToolCall {
6665 id: "call_2".to_string(),
6666 tool_type: "function".to_string(),
6667 function: ferrum_types::ApiFunctionCall {
6668 name: "clock".to_string(),
6669 arguments: "{}".to_string(),
6670 },
6671 },
6672 ],
6673 tool_call_id: None,
6674 function_call: None,
6675 },
6676 finish_reason: Some("tool_calls".to_string()),
6677 })
6678 }
6679
6680 fn router_with_stub_without_stream_usage(text: &str) -> Router {
6681 AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
6682 }
6683
6684 fn router_without_llm() -> Router {
6685 AxumServer::from_state(AppState::default()).build_router()
6686 }
6687
6688 fn router_with_failing_llm() -> Router {
6689 AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
6690 }
6691
6692 fn router_with_failing_llm_and_request_dump_dir(request_dump_dir: PathBuf) -> Router {
6693 AxumServer::from_state(
6694 AppState::default()
6695 .with_llm(Arc::new(FailingLlm::new()))
6696 .with_request_dump_dir(Some(request_dump_dir)),
6697 )
6698 .build_router()
6699 }
6700
6701 fn router_with_failing_llm_request_dump_and_profile(
6702 request_dump_dir: PathBuf,
6703 profile_jsonl: PathBuf,
6704 ) -> Router {
6705 AxumServer::from_state(
6706 AppState::default()
6707 .with_llm(Arc::new(FailingLlm::new()))
6708 .with_request_dump_dir(Some(request_dump_dir))
6709 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6710 .with_profile_jsonl(Some(profile_jsonl)),
6711 )
6712 .build_router()
6713 }
6714
6715 fn router_with_resource_exhausted_llm_and_request_dump_dir(
6716 request_dump_dir: PathBuf,
6717 ) -> Router {
6718 AxumServer::from_state(
6719 AppState::default()
6720 .with_llm(Arc::new(FailingLlm::resource_exhausted()))
6721 .with_request_dump_dir(Some(request_dump_dir)),
6722 )
6723 .build_router()
6724 }
6725
6726 fn router_with_stream_chunk_failing_llm() -> Router {
6727 AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
6728 }
6729
6730 fn router_with_stream_chunk_failing_llm_and_request_dump_dir(
6731 request_dump_dir: PathBuf,
6732 ) -> Router {
6733 AxumServer::from_state(
6734 AppState::default()
6735 .with_llm(Arc::new(FailingLlm::after_stream_start()))
6736 .with_request_dump_dir(Some(request_dump_dir)),
6737 )
6738 .build_router()
6739 }
6740
6741 fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
6742 let engine = Arc::new(CapturingLlm::new());
6743 let registry = ServedModelRegistry::try_new(
6744 "qwen3",
6745 ServedModelKind::Llm,
6746 vec![
6747 "qwen3".to_string(),
6748 "stub-model".to_string(),
6749 "served-alias".to_string(),
6750 ],
6751 vec![],
6752 )
6753 .unwrap();
6754 let router = AxumServer::from_llm(engine.clone())
6755 .with_served_model_registry(registry)
6756 .build_router();
6757 (router, engine)
6758 }
6759
6760 fn unique_request_dump_dir(test_name: &str) -> PathBuf {
6761 let path =
6762 std::env::temp_dir().join(format!("ferrum-server-{test_name}-{}", Uuid::new_v4()));
6763 fs::create_dir_all(&path).expect("create request dump dir");
6764 path
6765 }
6766
6767 fn unique_profile_jsonl(test_name: &str) -> PathBuf {
6768 std::env::temp_dir().join(format!(
6769 "ferrum-server-{test_name}-{}.jsonl",
6770 Uuid::new_v4()
6771 ))
6772 }
6773
6774 fn only_replay_bundle(root: &Path) -> PathBuf {
6775 let mut dirs = fs::read_dir(root)
6776 .expect("read request dump dir")
6777 .filter_map(|entry| {
6778 let path = entry.expect("dir entry").path();
6779 path.is_dir().then_some(path)
6780 })
6781 .collect::<Vec<_>>();
6782 dirs.sort();
6783 assert_eq!(
6784 dirs.len(),
6785 1,
6786 "expected exactly one replay bundle in {root:?}"
6787 );
6788 dirs.remove(0)
6789 }
6790
6791 fn read_json_file(path: impl AsRef<Path>) -> Value {
6792 let path = path.as_ref();
6793 let text = fs::read_to_string(path).unwrap_or_else(|err| {
6794 panic!("failed to read {}: {}", path.display(), err);
6795 });
6796 serde_json::from_str(&text).unwrap_or_else(|err| {
6797 panic!("failed to parse {}: {}", path.display(), err);
6798 })
6799 }
6800
6801 fn read_profile_events(path: &Path) -> Vec<Value> {
6802 let text = fs::read_to_string(path)
6803 .unwrap_or_else(|err| panic!("failed to read {}: {}", path.display(), err));
6804 text.lines()
6805 .filter(|line| !line.trim().is_empty())
6806 .map(|line| serde_json::from_str::<Value>(line).expect("profile event json"))
6807 .collect()
6808 }
6809
6810 fn assert_chat_failure_replay_bundle(
6811 root: &Path,
6812 expected_phase: &str,
6813 expected_error_kind: &str,
6814 expected_message: &str,
6815 ) {
6816 let bundle = only_replay_bundle(root);
6817 let request = read_json_file(bundle.join("request.json"));
6818 let request_id = request["request_id"]
6819 .as_str()
6820 .expect("request id")
6821 .to_string();
6822 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
6823 assert_eq!(bad_scan["request_id"], request_id);
6824 assert_eq!(bad_scan["failure_kind"], "error");
6825 assert_eq!(bad_scan["failure_phase"], expected_phase);
6826 assert_eq!(bad_scan["error_kind"], expected_error_kind);
6827
6828 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
6829 assert_eq!(diagnostics["request_id"], request_id);
6830 assert_eq!(diagnostics["failure_kind"], "error");
6831 assert_eq!(diagnostics["first_failure_event"]["phase"], expected_phase);
6832 assert_eq!(
6833 diagnostics["first_failure_event"]["error_kind"],
6834 expected_error_kind
6835 );
6836 assert_eq!(diagnostics["nearest_request_id"], request_id);
6837 assert!(diagnostics["log_excerpt"]
6838 .as_str()
6839 .expect("log excerpt")
6840 .contains(expected_message));
6841 assert!(bundle.join("replay.command.json").is_file());
6842 }
6843
6844 fn assert_chat_success_replay_bundle(
6845 root: &Path,
6846 expected_token_ids: &[u32],
6847 expected_finish_reason: &str,
6848 expected_output_text: &str,
6849 ) {
6850 let bundle = only_replay_bundle(root);
6851 let request = read_json_file(bundle.join("request.json"));
6852 let request_id = request["request_id"]
6853 .as_str()
6854 .expect("request id")
6855 .to_string();
6856 let prompt_tokens = read_json_file(bundle.join("prompt_token_ids.json"));
6857 assert_eq!(prompt_tokens["request_id"], request_id);
6858 assert_eq!(prompt_tokens["token_ids"], json!([101, 202, 303]));
6859 assert_eq!(prompt_tokens["token_count"], 3);
6860 assert!(prompt_tokens["unavailable_reason"].is_null());
6861 let output_tokens = read_json_file(bundle.join("output_token_ids.json"));
6862 assert_eq!(output_tokens["request_id"], request_id);
6863 assert_eq!(output_tokens["token_ids"], json!(expected_token_ids));
6864 assert_eq!(output_tokens["token_count"], expected_token_ids.len());
6865 assert_eq!(output_tokens["finish_reason"], expected_finish_reason);
6866 assert!(output_tokens["unavailable_reason"].is_null());
6867
6868 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
6869 assert_eq!(bad_scan["request_id"], request_id);
6870 assert_eq!(bad_scan["bad_output"], false);
6871 assert_eq!(bad_scan["failure_kind"], serde_json::Value::Null);
6872 assert_eq!(
6873 bad_scan["output_chars"],
6874 expected_output_text.chars().count()
6875 );
6876 assert_eq!(
6877 bad_scan["classified_output_sha256"],
6878 sha256_hex(expected_output_text.as_bytes())
6879 );
6880
6881 let output_text_bytes = fs::read(bundle.join("output_text.txt")).unwrap();
6882 assert_eq!(bad_scan["output_sha256"], sha256_hex(&output_text_bytes));
6883 let output_text = String::from_utf8(output_text_bytes).unwrap();
6884 assert!(output_text.contains("[redacted actual output]"));
6885 assert!(output_text.contains(&format!(
6886 "sha256={}",
6887 sha256_hex(expected_output_text.as_bytes())
6888 )));
6889 assert!(output_text.contains(&format!("chars={}", expected_output_text.chars().count())));
6890
6891 let replay_body = read_json_file(bundle.join("replay_body.json"));
6892 assert_eq!(replay_body["messages"][0]["role"], "user");
6893 assert_eq!(replay_body["messages"][0]["content"], "[redacted]");
6894 assert_eq!(replay_body["messages"][0]["content_redacted"], true);
6895
6896 let replay = read_json_file(bundle.join("replay.command.json"));
6897 assert_eq!(replay["requires_running_server"], true);
6898 let argv = replay["argv"].as_array().expect("replay argv");
6899 assert!(argv.iter().any(|item| item == "--data-binary"));
6900 assert!(argv.iter().any(|item| {
6901 item.as_str()
6902 .is_some_and(|value| value.starts_with('@') && value.ends_with("replay_body.json"))
6903 }));
6904 assert_eq!(replay["engine_replay"]["requires_http_server"], false);
6905 let engine_argv = replay["engine_replay"]["argv"]
6906 .as_array()
6907 .expect("engine replay argv");
6908 assert!(engine_argv.iter().any(|item| item == "replay-bundle"));
6909 }
6910
6911 fn router_with_capturing_llm_and_template(
6912 template: ModelChatTemplate,
6913 ) -> (Router, Arc<CapturingLlm>) {
6914 router_with_capturing_llm_and_template_default(template, None)
6915 }
6916
6917 fn router_with_capturing_llm_and_template_default(
6918 template: ModelChatTemplate,
6919 default_enable_thinking: Option<bool>,
6920 ) -> (Router, Arc<CapturingLlm>) {
6921 let engine = Arc::new(CapturingLlm::new());
6922 let registry = ServedModelRegistry::try_new(
6923 "qwen3",
6924 ServedModelKind::Llm,
6925 vec!["served-alias".to_string()],
6926 vec![],
6927 )
6928 .unwrap();
6929 let router = AxumServer::from_llm(engine.clone())
6930 .with_served_model_registry(registry)
6931 .with_prompt_template(Some(template))
6932 .with_default_enable_thinking(default_enable_thinking)
6933 .build_router();
6934 (router, engine)
6935 }
6936
6937 fn qwen36_chat_template() -> ModelChatTemplate {
6938 ModelChatTemplate::new(
6939 include_str!("../tests/fixtures/chat_template/Qwen__Qwen3.6-35B-A3B/template.jinja"),
6940 "Qwen/Qwen3.6-35B-A3B",
6941 )
6942 }
6943
6944 async fn capture_qwen36_tool_history_request(
6945 reasoning_fields: Value,
6946 stream: bool,
6947 ) -> InferenceRequest {
6948 let mut assistant = json!({
6949 "role": "assistant",
6950 "content": null,
6951 "tool_calls": [{
6952 "id": "call_1",
6953 "type": "function",
6954 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
6955 }]
6956 });
6957 assistant
6958 .as_object_mut()
6959 .expect("assistant message object")
6960 .extend(
6961 reasoning_fields
6962 .as_object()
6963 .expect("reasoning fields object")
6964 .clone(),
6965 );
6966 let (router, engine) = router_with_capturing_llm_and_template(qwen36_chat_template());
6967 let response = post_json(
6968 router,
6969 "/v1/chat/completions",
6970 json!({
6971 "model": "served-alias",
6972 "messages": [
6973 {"role": "user", "content": "Use the weather tool."},
6974 assistant,
6975 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
6976 ],
6977 "tools": [{
6978 "type": "function",
6979 "function": {
6980 "name": "weather",
6981 "description": "Get weather",
6982 "parameters": {
6983 "type": "object",
6984 "properties": {"city": {"type": "string"}},
6985 "required": ["city"]
6986 }
6987 }
6988 }],
6989 "stream": stream
6990 }),
6991 )
6992 .await;
6993 assert_eq!(response.status(), AxumStatusCode::OK);
6994 if stream {
6995 assert!(response_text(response).await.contains("[DONE]"));
6996 }
6997 engine.last_request()
6998 }
6999
7000 fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
7001 let engine = Arc::new(CapturingLlm::new());
7002 let router = AxumServer::from_llm(engine.clone())
7003 .with_lora_adapters(
7004 "qwen3",
7005 vec![LoraAdapterModel::new(
7006 "sql",
7007 "qwen3:sql",
7008 "/tmp/sql-adapter",
7009 )],
7010 )
7011 .unwrap()
7012 .build_router();
7013 (router, engine)
7014 }
7015
7016 fn router_with_stub_embed() -> Router {
7017 AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
7018 }
7019
7020 fn router_with_stub_transcribe() -> Router {
7021 AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
7022 }
7023
7024 fn router_with_stub_tts() -> Router {
7025 AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
7026 }
7027
7028 async fn post_json(app: Router, path: &str, body: Value) -> Response {
7029 app.oneshot(
7030 Request::builder()
7031 .method("POST")
7032 .uri(path)
7033 .header(header::CONTENT_TYPE, "application/json")
7034 .body(Body::from(body.to_string()))
7035 .expect("request"),
7036 )
7037 .await
7038 .expect("route response")
7039 }
7040
7041 async fn post_json_with_benchmark_correlation(
7042 app: Router,
7043 path: &str,
7044 body: Value,
7045 correlation: &BenchmarkRequestCorrelation,
7046 ) -> Response {
7047 app.oneshot(
7048 Request::builder()
7049 .method("POST")
7050 .uri(path)
7051 .header(header::CONTENT_TYPE, "application/json")
7052 .header(BENCHMARK_RUN_ID_HEADER, &correlation.benchmark_run_id)
7053 .header(BENCHMARK_CELL_ID_HEADER, &correlation.cell_id)
7054 .header(
7055 BENCHMARK_REPEAT_INDEX_HEADER,
7056 correlation.repeat_index.to_string(),
7057 )
7058 .header(BENCHMARK_PHASE_HEADER, correlation.phase.as_str())
7059 .header(
7060 BENCHMARK_REQUEST_INDEX_HEADER,
7061 correlation.request_index.to_string(),
7062 )
7063 .body(Body::from(body.to_string()))
7064 .expect("request"),
7065 )
7066 .await
7067 .expect("route response")
7068 }
7069
7070 async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
7071 app.oneshot(
7072 Request::builder()
7073 .method("POST")
7074 .uri(path)
7075 .header(header::CONTENT_TYPE, "application/json")
7076 .body(Body::from(body.to_string()))
7077 .expect("request"),
7078 )
7079 .await
7080 .expect("route response")
7081 }
7082
7083 async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
7084 app.oneshot(
7085 Request::builder()
7086 .method("POST")
7087 .uri(path)
7088 .header(
7089 header::CONTENT_TYPE,
7090 format!("multipart/form-data; boundary={boundary}"),
7091 )
7092 .body(Body::from(body.to_string()))
7093 .expect("request"),
7094 )
7095 .await
7096 .expect("route response")
7097 }
7098
7099 async fn get(app: Router, path: &str) -> Response {
7100 app.oneshot(
7101 Request::builder()
7102 .method("GET")
7103 .uri(path)
7104 .body(Body::empty())
7105 .expect("request"),
7106 )
7107 .await
7108 .expect("route response")
7109 }
7110
7111 async fn response_json(response: Response) -> Value {
7112 let bytes = to_bytes(response.into_body(), usize::MAX)
7113 .await
7114 .expect("body bytes");
7115 serde_json::from_slice(&bytes).expect("json body")
7116 }
7117
7118 async fn response_text(response: Response) -> String {
7119 let bytes = to_bytes(response.into_body(), usize::MAX)
7120 .await
7121 .expect("body bytes");
7122 String::from_utf8(bytes.to_vec()).expect("utf8 body")
7123 }
7124
7125 fn responses_sse_json_events(body: &str) -> Vec<Value> {
7126 body.lines()
7127 .filter_map(|line| line.strip_prefix("data: "))
7128 .filter(|data| *data != "[DONE]")
7129 .map(|data| serde_json::from_str(data).expect("Responses SSE JSON event"))
7130 .collect()
7131 }
7132
7133 async fn response_bytes(response: Response) -> Vec<u8> {
7134 to_bytes(response.into_body(), usize::MAX)
7135 .await
7136 .expect("body bytes")
7137 .to_vec()
7138 }
7139
7140 async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
7141 let response = error.into_response();
7142 let status = response.status();
7143 (status, response_json(response).await)
7144 }
7145
7146 fn assert_openai_stream_error(body: &str, expected_message: &str) {
7147 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7148 assert!(
7149 body.contains("\"error\":{\"message\":\""),
7150 "stream failure should emit OpenAI error envelope: {body}"
7151 );
7152 assert!(
7153 body.contains(expected_message),
7154 "stream failure should include engine error message {expected_message:?}: {body}"
7155 );
7156 assert!(
7157 body.contains("\"type\":\"internal_server_error\""),
7158 "stream failure should use internal_server_error: {body}"
7159 );
7160 assert!(
7161 !body.contains("{\"error\":\""),
7162 "stream failure must not use legacy bare error payload: {body}"
7163 );
7164 }
7165
7166 fn chat_request(extra: Value) -> ChatCompletionsRequest {
7167 let mut value = json!({
7168 "model": "stub-model",
7169 "messages": [{"role": "user", "content": "hello"}],
7170 "max_tokens": 8
7171 });
7172 let obj = value.as_object_mut().unwrap();
7173 for (k, v) in extra.as_object().unwrap() {
7174 obj.insert(k.clone(), v.clone());
7175 }
7176 serde_json::from_value(value).expect("chat request")
7177 }
7178
7179 #[tokio::test]
7180 async fn responses_route_returns_sync_text_and_usage() {
7181 let response = post_json(
7182 router_with_stub("hello from ferrum"),
7183 "/v1/responses",
7184 json!({
7185 "model": "stub-model",
7186 "input": "hello",
7187 "store": false
7188 }),
7189 )
7190 .await;
7191 assert_eq!(response.status(), AxumStatusCode::OK);
7192 let body = response_json(response).await;
7193 assert_eq!(body["object"], "response");
7194 assert_eq!(body["status"], "completed");
7195 assert_eq!(body["store"], false);
7196 assert_eq!(body["output"][0]["type"], "message");
7197 assert_eq!(body["output"][0]["phase"], "final_answer");
7198 assert_eq!(body["output"][0]["content"][0]["text"], "hello from ferrum");
7199 assert_eq!(body["usage"]["input_tokens"], 7);
7200 assert_eq!(body["usage"]["output_tokens"], 2);
7201 assert_eq!(body["usage"]["total_tokens"], 9);
7202 assert_eq!(body["presence_penalty"], 0.0);
7203 assert_eq!(body["frequency_penalty"], 0.0);
7204 }
7205
7206 #[tokio::test]
7207 async fn responses_route_streams_ordered_text_events_once() {
7208 let response = post_json(
7209 router_with_stub_stream_chunks(&["he", "llo"]),
7210 "/v1/responses",
7211 json!({
7212 "model": "stub-model",
7213 "input": [{"role": "user", "content": "say hello"}],
7214 "stream": true
7215 }),
7216 )
7217 .await;
7218 assert_eq!(response.status(), AxumStatusCode::OK);
7219 let body = response_text(response).await;
7220 for event in [
7221 "response.created",
7222 "response.output_item.added",
7223 "response.output_text.delta",
7224 "response.output_text.done",
7225 "response.output_item.done",
7226 "response.completed",
7227 ] {
7228 assert!(
7229 body.contains(&format!("event: {event}")),
7230 "missing {event}: {body}"
7231 );
7232 }
7233 assert_eq!(
7234 body.matches("event: response.completed").count(),
7235 1,
7236 "completed must be emitted exactly once: {body}"
7237 );
7238 assert!(
7239 body.contains("\"delta\":\"he\""),
7240 "missing first delta: {body}"
7241 );
7242 assert!(
7243 body.contains("\"delta\":\"llo\""),
7244 "missing second delta: {body}"
7245 );
7246 assert!(body.contains("\"input_tokens\":5"), "missing usage: {body}");
7247 let events = responses_sse_json_events(&body);
7248 let message_added = events
7249 .iter()
7250 .find(|event| {
7251 event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7252 })
7253 .expect("message item added");
7254 assert!(
7255 message_added["item"].get("phase").is_none(),
7256 "stream must not guess phase before later tool calls are known: {body}"
7257 );
7258 let message_done = events
7259 .iter()
7260 .find(|event| {
7261 event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7262 })
7263 .expect("message item done");
7264 assert_eq!(message_done["item"]["phase"], "final_answer");
7265 let terminal = events
7266 .iter()
7267 .find(|event| event["type"] == "response.completed")
7268 .expect("completed response");
7269 assert_eq!(terminal["response"]["output"][0]["phase"], "final_answer");
7270 let completed = body
7271 .find("event: response.completed")
7272 .expect("completed event");
7273 let done = body.find("data: [DONE]").expect("terminal DONE marker");
7274 assert!(
7275 completed < done,
7276 "DONE must follow response.completed: {body}"
7277 );
7278 }
7279
7280 #[tokio::test]
7281 async fn responses_route_supports_stateless_function_round_trip() {
7282 let tool = json!({
7283 "type": "function",
7284 "name": "weather",
7285 "description": "Get weather",
7286 "parameters": {
7287 "type": "object",
7288 "properties": {"city": {"type": "string"}},
7289 "required": ["city"]
7290 }
7291 });
7292 let first = post_json(
7293 router_with_stub_api_response("", weather_tool_api_response()),
7294 "/v1/responses",
7295 json!({
7296 "model": "stub-model",
7297 "input": "Use the weather tool",
7298 "tools": [tool.clone()],
7299 "tool_choice": "auto"
7300 }),
7301 )
7302 .await;
7303 assert_eq!(first.status(), AxumStatusCode::OK);
7304 let first_body = response_json(first).await;
7305 let call = first_body["output"][0].clone();
7306 assert_eq!(call["type"], "function_call");
7307 assert_eq!(call["call_id"], "call_1");
7308 assert_eq!(call["name"], "weather");
7309 assert_eq!(call["arguments"], "{\"city\":\"Paris\"}");
7310
7311 let second = post_json(
7312 router_with_stub("weather received"),
7313 "/v1/responses",
7314 json!({
7315 "model": "stub-model",
7316 "input": [
7317 {"role": "user", "content": "Use the weather tool"},
7318 call,
7319 {"type": "function_call_output", "call_id": "call_1", "output": "sunny"}
7320 ],
7321 "tools": [tool]
7322 }),
7323 )
7324 .await;
7325 assert_eq!(second.status(), AxumStatusCode::OK);
7326 let second_body = response_json(second).await;
7327 assert_eq!(
7328 second_body["output"][0]["content"][0]["text"],
7329 "weather received"
7330 );
7331 }
7332
7333 #[tokio::test]
7334 async fn responses_route_marks_text_before_calls_as_commentary() {
7335 let request = || {
7336 json!({
7337 "model": "stub-model",
7338 "input": "Use the weather tool",
7339 "stream": false,
7340 "tools": [{
7341 "type": "function",
7342 "name": "weather",
7343 "parameters": {"type": "object"}
7344 }]
7345 })
7346 };
7347 let sync = post_json(
7348 router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7349 "/v1/responses",
7350 request(),
7351 )
7352 .await;
7353 assert_eq!(sync.status(), AxumStatusCode::OK);
7354 let sync = response_json(sync).await;
7355 assert_eq!(sync["output"][0]["type"], "message");
7356 assert_eq!(sync["output"][0]["phase"], "commentary");
7357 assert_eq!(sync["output"][1]["type"], "function_call");
7358
7359 let mut stream_request = request();
7360 stream_request["stream"] = json!(true);
7361 let stream = post_json(
7362 router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7363 "/v1/responses",
7364 stream_request,
7365 )
7366 .await;
7367 assert_eq!(stream.status(), AxumStatusCode::OK);
7368 let body = response_text(stream).await;
7369 let events = responses_sse_json_events(&body);
7370 let message_added = events
7371 .iter()
7372 .find(|event| {
7373 event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7374 })
7375 .expect("message item added");
7376 assert!(message_added["item"].get("phase").is_none());
7377 let message_done = events
7378 .iter()
7379 .find(|event| {
7380 event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7381 })
7382 .expect("message item done");
7383 assert_eq!(message_done["item"]["phase"], "commentary");
7384 let terminal = events
7385 .iter()
7386 .find(|event| event["type"] == "response.completed")
7387 .expect("completed response");
7388 assert_eq!(terminal["response"]["output"][0]["phase"], "commentary");
7389 assert_eq!(terminal["response"]["output"][1]["type"], "function_call");
7390 }
7391
7392 #[tokio::test]
7393 async fn responses_route_accepts_real_caller_owned_second_turn_shape() {
7394 let response = post_json(
7395 router_with_stub("You first said hello."),
7396 "/v1/responses",
7397 json!({
7398 "model": "stub-model",
7399 "instructions": "Answer from the supplied history.",
7400 "input": [
7401 {
7402 "type": "message",
7403 "role": "user",
7404 "content": [{"type": "input_text", "text": "Hello"}]
7405 },
7406 {
7407 "type": "message",
7408 "role": "assistant",
7409 "content": [{"type": "output_text", "text": "Hi there!"}]
7410 },
7411 {
7412 "type": "reasoning",
7413 "encrypted_content": null,
7414 "summary": []
7415 },
7416 {
7417 "type": "message",
7418 "role": "user",
7419 "content": [{"type": "input_text", "text": "What did I say first?"}]
7420 }
7421 ],
7422 "store": false,
7423 "stream": false,
7424 "include": ["reasoning.encrypted_content"],
7425 "parallel_tool_calls": false,
7426 "prompt_cache_key": "thread-1",
7427 "reasoning": {"effort": "high", "summary": "auto"}
7428 }),
7429 )
7430 .await;
7431 assert_eq!(response.status(), AxumStatusCode::OK);
7432 let body = response_json(response).await;
7433 assert_eq!(
7434 body["output"][0]["content"][0]["text"],
7435 "You first said hello."
7436 );
7437 assert_eq!(body["parallel_tool_calls"], false);
7438 assert_eq!(body["prompt_cache_key"], "thread-1");
7439 assert_eq!(body["reasoning"]["effort"], "high");
7440 }
7441
7442 #[tokio::test]
7443 async fn responses_route_merges_instructions_with_leading_developer_message() {
7444 let template = ModelChatTemplate::new(
7445 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7446 "strict-leading-system-template",
7447 );
7448 let response = post_json(
7449 router_with_stub_and_template("ok", template),
7450 "/v1/responses",
7451 json!({
7452 "model": "stub-model",
7453 "instructions": "Top-level instructions",
7454 "input": [
7455 {"type": "message", "role": "developer", "content": "Developer instructions"},
7456 {"type": "message", "role": "user", "content": "Hello"}
7457 ]
7458 }),
7459 )
7460 .await;
7461 assert_eq!(response.status(), AxumStatusCode::OK);
7462 }
7463
7464 #[tokio::test]
7465 async fn responses_route_adapts_interleaved_system_for_strict_template() {
7466 let template = ModelChatTemplate::new(
7467 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}{% endfor %}{% if messages|length != 2 %}{{ raise_exception('expected two messages') }}{% endif %}{% if messages[0].role != 'system' %}{{ raise_exception('system must be first') }}{% endif %}{% if messages[0].content != 'Initial instructions\\n\\nDeferred tool instructions' %}{{ raise_exception('system instructions were not preserved') }}{% endif %}[{{ messages[0].role }}]{{ messages[0].content }}[{{ messages[1].role }}]{{ messages[1].content }}",
7468 "strict-leading-system-template",
7469 );
7470 let response = post_json(
7471 router_with_stub_and_template("ok", template),
7472 "/v1/responses",
7473 json!({
7474 "model": "stub-model",
7475 "input": [
7476 {"role": "system", "content": "Initial instructions"},
7477 {"role": "user", "content": "Use the available tool"},
7478 {"role": "developer", "content": "Deferred tool instructions"}
7479 ]
7480 }),
7481 )
7482 .await;
7483 assert_eq!(response.status(), AxumStatusCode::OK);
7484 }
7485
7486 #[tokio::test]
7487 async fn responses_route_keeps_phase_aligned_through_system_injection() {
7488 let template = ModelChatTemplate::new(
7489 "{% if messages|length != 3 %}{{ raise_exception('expected three messages') }}{% endif %}{% if messages[0].role != 'system' %}{{ raise_exception('system must be first') }}{% endif %}{% if messages[1].role != 'assistant' or messages[1].phase != 'commentary' %}{{ raise_exception('assistant phase was not preserved') }}{% endif %}{% if messages[2].role != 'user' or messages[2].phase is defined %}{{ raise_exception('phase metadata shifted') }}{% endif %}[assistant]",
7490 "phase-alignment-template",
7491 );
7492 let response = post_json(
7493 router_with_stub_and_template(r#"{"ok":true}"#, template),
7494 "/v1/responses",
7495 json!({
7496 "model": "stub-model",
7497 "instructions": "Top-level instructions",
7498 "input": [
7499 {
7500 "type": "message",
7501 "role": "assistant",
7502 "phase": "commentary",
7503 "content": "I will inspect."
7504 },
7505 {"type": "message", "role": "user", "content": "Continue"}
7506 ],
7507 "text": {"format": {"type": "json_object"}}
7508 }),
7509 )
7510 .await;
7511 assert_eq!(response.status(), AxumStatusCode::OK);
7512 }
7513
7514 #[tokio::test]
7515 async fn responses_route_can_disable_interleaved_system_coalescing() {
7516 let template = ModelChatTemplate::new(
7517 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7518 "strict-leading-system-template",
7519 );
7520 let router = AxumServer::from_state(
7521 AppState::default()
7522 .with_llm(Arc::new(StubLlm::new("ok")))
7523 .with_prompt_template(Some(template))
7524 .with_interleaved_system_coalescing(false),
7525 )
7526 .build_router();
7527 let response = post_json(
7528 router,
7529 "/v1/responses",
7530 json!({
7531 "model": "stub-model",
7532 "input": [
7533 {"role": "system", "content": "Initial instructions"},
7534 {"role": "user", "content": "Use the available tool"},
7535 {"role": "developer", "content": "Deferred tool instructions"}
7536 ]
7537 }),
7538 )
7539 .await;
7540 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
7541 let body = response_json(response).await;
7542 assert!(
7543 body.to_string()
7544 .contains("System message must be at the beginning."),
7545 "{body}"
7546 );
7547 }
7548
7549 #[tokio::test]
7550 async fn chat_route_applies_and_can_disable_interleaved_system_coalescing() {
7551 let template = || {
7552 ModelChatTemplate::new(
7553 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7554 "strict-leading-system-template",
7555 )
7556 };
7557 let request = || {
7558 json!({
7559 "model": "stub-model",
7560 "messages": [
7561 {"role": "system", "content": "Initial instructions"},
7562 {"role": "user", "content": "Use the available tool"},
7563 {"role": "system", "content": "Deferred tool instructions"}
7564 ]
7565 })
7566 };
7567
7568 let enabled = post_json(
7569 router_with_stub_and_template("ok", template()),
7570 "/v1/chat/completions",
7571 request(),
7572 )
7573 .await;
7574 assert_eq!(enabled.status(), AxumStatusCode::OK);
7575
7576 let consecutive = post_json(
7577 router_with_stub_and_template("ok", template()),
7578 "/v1/chat/completions",
7579 json!({
7580 "model": "stub-model",
7581 "messages": [
7582 {"role": "system", "content": "Initial instructions"},
7583 {"role": "system", "content": "Deferred tool instructions"},
7584 {"role": "user", "content": "Use the available tool"}
7585 ]
7586 }),
7587 )
7588 .await;
7589 assert_eq!(consecutive.status(), AxumStatusCode::OK);
7590
7591 let disabled_router = AxumServer::from_state(
7592 AppState::default()
7593 .with_llm(Arc::new(StubLlm::new("ok")))
7594 .with_prompt_template(Some(template()))
7595 .with_interleaved_system_coalescing(false),
7596 )
7597 .build_router();
7598 let disabled = post_json(disabled_router, "/v1/chat/completions", request()).await;
7599 assert_eq!(disabled.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
7600 let body = response_json(disabled).await;
7601 assert!(
7602 body.to_string()
7603 .contains("System message must be at the beginning."),
7604 "{body}"
7605 );
7606 }
7607
7608 #[tokio::test]
7609 async fn responses_route_keeps_structured_output_to_one_leading_system_message() {
7610 let template = ModelChatTemplate::new(
7611 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7612 "strict-leading-system-template",
7613 );
7614 let response = post_json(
7615 router_with_stub_and_template(r#"{"ok":true}"#, template),
7616 "/v1/responses",
7617 json!({
7618 "model": "stub-model",
7619 "instructions": "Top-level instructions",
7620 "input": [
7621 {"type": "message", "role": "developer", "content": "Developer instructions"},
7622 {"type": "message", "role": "user", "content": "Return JSON"}
7623 ],
7624 "text": {"format": {"type": "json_object"}}
7625 }),
7626 )
7627 .await;
7628 assert_eq!(response.status(), AxumStatusCode::OK);
7629 let body = response_json(response).await;
7630 assert_eq!(body["output"][0]["content"][0]["text"], r#"{"ok":true}"#);
7631 }
7632
7633 #[tokio::test]
7634 async fn responses_route_output_can_be_replayed_with_readable_reasoning() {
7635 let first = post_json(
7636 router_with_stub("<think>Checked the supplied facts.</think>\nFirst answer"),
7637 "/v1/responses",
7638 json!({
7639 "model": "stub-model",
7640 "input": "First question",
7641 "include": ["reasoning.encrypted_content"]
7642 }),
7643 )
7644 .await;
7645 assert_eq!(first.status(), AxumStatusCode::OK);
7646 let first_body = response_json(first).await;
7647 assert_eq!(first_body["output"][0]["type"], "reasoning");
7648 assert_eq!(
7649 first_body["output"][0]["content"][0]["text"],
7650 "Checked the supplied facts."
7651 );
7652 assert_eq!(first_body["output"][0]["encrypted_content"], Value::Null);
7653 assert_eq!(first_body["output"][1]["type"], "message");
7654
7655 let mut input = vec![json!({
7656 "type": "message",
7657 "role": "user",
7658 "content": [{"type": "input_text", "text": "First question"}]
7659 })];
7660 input.extend(first_body["output"].as_array().unwrap().iter().cloned());
7661 input.push(json!({
7662 "type": "message",
7663 "role": "user",
7664 "content": [{"type": "input_text", "text": "Continue"}]
7665 }));
7666 let second = post_json(
7667 router_with_stub("Second answer"),
7668 "/v1/responses",
7669 json!({"model": "stub-model", "input": input}),
7670 )
7671 .await;
7672 assert_eq!(second.status(), AxumStatusCode::OK);
7673 let second_body = response_json(second).await;
7674 assert_eq!(
7675 second_body["output"][0]["content"][0]["text"],
7676 "Second answer"
7677 );
7678 }
7679
7680 #[tokio::test]
7681 async fn responses_route_streams_reasoning_before_text_with_stable_indices() {
7682 let response = post_json(
7683 router_with_stub_stream_chunks(&["<think>inspect", " history</think>\nfinal"]),
7684 "/v1/responses",
7685 json!({
7686 "model": "stub-model",
7687 "input": "answer",
7688 "stream": true,
7689 "include": ["reasoning.encrypted_content"]
7690 }),
7691 )
7692 .await;
7693 assert_eq!(response.status(), AxumStatusCode::OK);
7694 let body = response_text(response).await;
7695 let events = responses_sse_json_events(&body);
7696 for (sequence, event) in events.iter().enumerate() {
7697 assert_eq!(
7698 event["sequence_number"], sequence,
7699 "Responses sequence numbers must be contiguous: {body}"
7700 );
7701 }
7702 for event in [
7703 "response.reasoning_text.delta",
7704 "response.reasoning_text.done",
7705 "response.output_text.delta",
7706 "response.completed",
7707 ] {
7708 assert!(
7709 body.contains(&format!("event: {event}")),
7710 "missing {event}: {body}"
7711 );
7712 }
7713 let reasoning_done = body
7714 .find("event: response.reasoning_text.done")
7715 .expect("reasoning done");
7716 let text_added = body[reasoning_done..]
7717 .find("event: response.output_item.added")
7718 .map(|offset| reasoning_done + offset)
7719 .expect("text item added");
7720 assert!(
7721 reasoning_done < text_added,
7722 "reasoning must finish before text: {body}"
7723 );
7724 assert!(
7725 body.contains("\"output_index\":0,\"content_index\":0,\"delta\":\"inspect"),
7726 "reasoning must use output index 0: {body}"
7727 );
7728 assert!(
7729 body.contains("\"output_index\":1,\"content_index\":0,\"delta\":\"final"),
7730 "text must use output index 1: {body}"
7731 );
7732 let reasoning_added = events
7733 .iter()
7734 .find(|event| {
7735 event["type"] == "response.output_item.added"
7736 && event["item"]["type"] == "reasoning"
7737 })
7738 .expect("reasoning item added");
7739 assert_eq!(reasoning_added["item"]["status"], "in_progress");
7740 let reasoning_part_added = events
7741 .iter()
7742 .find(|event| {
7743 event["type"] == "response.content_part.added"
7744 && event["part"]["type"] == "reasoning_text"
7745 })
7746 .expect("reasoning content part added");
7747 assert_eq!(reasoning_part_added["output_index"], 0);
7748 let reasoning_item_done = events
7749 .iter()
7750 .find(|event| {
7751 event["type"] == "response.output_item.done" && event["item"]["type"] == "reasoning"
7752 })
7753 .expect("reasoning item done");
7754 assert_eq!(reasoning_item_done["item"]["status"], "completed");
7755 let terminal = events
7756 .iter()
7757 .find(|event| event["type"] == "response.completed")
7758 .expect("terminal response");
7759 assert_eq!(
7760 terminal["response"]["output"][0],
7761 reasoning_item_done["item"]
7762 );
7763 assert!(
7764 body.contains("data: [DONE]"),
7765 "missing terminal marker: {body}"
7766 );
7767 }
7768
7769 #[tokio::test]
7770 async fn responses_route_streams_function_call_events() {
7771 let response = post_json(
7772 router_with_stub_api_response("", weather_tool_api_response()),
7773 "/v1/responses",
7774 json!({
7775 "model": "stub-model",
7776 "input": "Use the weather tool",
7777 "stream": true,
7778 "tools": [{
7779 "type": "function",
7780 "name": "weather",
7781 "parameters": {"type": "object"}
7782 }]
7783 }),
7784 )
7785 .await;
7786 assert_eq!(response.status(), AxumStatusCode::OK);
7787 let body = response_text(response).await;
7788 assert!(
7789 body.contains("event: response.function_call_arguments.delta"),
7790 "missing function delta: {body}"
7791 );
7792 assert!(
7793 body.contains("event: response.function_call_arguments.done"),
7794 "missing function done: {body}"
7795 );
7796 assert!(
7797 body.contains("\"call_id\":\"call_1\""),
7798 "missing call id: {body}"
7799 );
7800 assert_eq!(body.matches("event: response.completed").count(), 1);
7801 }
7802
7803 #[tokio::test]
7804 async fn responses_route_round_trips_namespace_identity_without_leaking_chat_alias() {
7805 let namespace_tool = json!({
7806 "type": "namespace",
7807 "name": "collaboration",
7808 "description": "Agent coordination tools",
7809 "tools": [{
7810 "type": "function",
7811 "name": "wait_agent",
7812 "parameters": {"type": "object"}
7813 }]
7814 });
7815 let sync = post_json(
7816 router_with_stub_api_response("", namespaced_tool_api_response()),
7817 "/v1/responses",
7818 json!({
7819 "model": "stub-model",
7820 "input": "Wait for the agent",
7821 "tools": [namespace_tool.clone()]
7822 }),
7823 )
7824 .await;
7825 assert_eq!(sync.status(), AxumStatusCode::OK);
7826 let sync_body = response_json(sync).await;
7827 assert_eq!(sync_body["output"][0]["type"], "function_call");
7828 assert_eq!(sync_body["output"][0]["namespace"], "collaboration");
7829 assert_eq!(sync_body["output"][0]["name"], "wait_agent");
7830
7831 let stream = post_json(
7832 router_with_stub_api_response("", namespaced_tool_api_response()),
7833 "/v1/responses",
7834 json!({
7835 "model": "stub-model",
7836 "input": "Wait for the agent",
7837 "tools": [namespace_tool],
7838 "stream": true
7839 }),
7840 )
7841 .await;
7842 assert_eq!(stream.status(), AxumStatusCode::OK);
7843 let stream_body = response_text(stream).await;
7844 let events = responses_sse_json_events(&stream_body);
7845 let function_events = events
7846 .iter()
7847 .filter(|event| {
7848 event["item"]["type"] == "function_call"
7849 || event["type"] == "response.function_call_arguments.done"
7850 })
7851 .collect::<Vec<_>>();
7852 assert!(!function_events.is_empty());
7853 for event in function_events {
7854 let value = event.get("item").unwrap_or(event);
7855 assert_eq!(value["namespace"], "collaboration");
7856 assert_eq!(value["name"], "wait_agent");
7857 }
7858 assert!(stream_body.contains("data: [DONE]"));
7859 assert!(!stream_body.contains("collaboration__wait_agent"));
7860 }
7861
7862 #[tokio::test]
7863 async fn responses_route_enforces_parallel_tool_call_constraint() {
7864 let tools = json!([
7865 {"type": "function", "name": "weather", "parameters": {"type": "object"}},
7866 {"type": "function", "name": "clock", "parameters": {"type": "object"}}
7867 ]);
7868 let sync = post_json(
7869 router_with_stub_api_response("", two_tool_api_response()),
7870 "/v1/responses",
7871 json!({
7872 "model": "stub-model",
7873 "input": "Use both tools",
7874 "tools": tools.clone(),
7875 "parallel_tool_calls": false
7876 }),
7877 )
7878 .await;
7879 assert_eq!(sync.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
7880
7881 let stream = post_json(
7882 router_with_stub_api_response("", two_tool_api_response()),
7883 "/v1/responses",
7884 json!({
7885 "model": "stub-model",
7886 "input": "Use both tools",
7887 "tools": tools,
7888 "parallel_tool_calls": false,
7889 "stream": true
7890 }),
7891 )
7892 .await;
7893 assert_eq!(stream.status(), AxumStatusCode::OK);
7894 let body = response_text(stream).await;
7895 assert!(
7896 body.contains("event: response.failed"),
7897 "missing failure: {body}"
7898 );
7899 assert!(
7900 !body.contains("event: response.completed"),
7901 "must not complete: {body}"
7902 );
7903 assert!(
7904 body.contains("data: [DONE]"),
7905 "missing terminal marker: {body}"
7906 );
7907 }
7908
7909 #[tokio::test]
7910 async fn responses_route_streams_incomplete_terminal_event() {
7911 let response = post_json(
7912 router_with_stub_finish_reason("partial", FinishReason::Length),
7913 "/v1/responses",
7914 json!({"model": "stub-model", "input": "answer", "stream": true}),
7915 )
7916 .await;
7917 assert_eq!(response.status(), AxumStatusCode::OK);
7918 let body = response_text(response).await;
7919 assert!(
7920 body.contains("event: response.incomplete"),
7921 "missing incomplete terminal event: {body}"
7922 );
7923 assert!(
7924 !body.contains("event: response.completed"),
7925 "incomplete response must not emit completed: {body}"
7926 );
7927 assert!(
7928 body.contains("data: [DONE]"),
7929 "missing terminal marker: {body}"
7930 );
7931 let events = responses_sse_json_events(&body);
7932 let output_done = events
7933 .iter()
7934 .find(|event| event["type"] == "response.output_item.done")
7935 .expect("incomplete output item done event");
7936 assert_eq!(output_done["item"]["status"], "incomplete");
7937 let terminal = events
7938 .iter()
7939 .find(|event| event["type"] == "response.incomplete")
7940 .expect("incomplete terminal event");
7941 assert_eq!(terminal["response"]["output"][0]["status"], "incomplete");
7942 }
7943
7944 #[tokio::test]
7945 async fn responses_route_marks_sync_length_output_incomplete() {
7946 let response = post_json(
7947 router_with_stub_finish_reason("partial", FinishReason::Length),
7948 "/v1/responses",
7949 json!({"model": "stub-model", "input": "answer"}),
7950 )
7951 .await;
7952 assert_eq!(response.status(), AxumStatusCode::OK);
7953 let body = response_json(response).await;
7954 assert_eq!(body["status"], "incomplete");
7955 assert_eq!(body["output"][0]["status"], "incomplete");
7956 }
7957
7958 #[tokio::test]
7959 async fn responses_route_rejects_state_and_non_function_tools() {
7960 for (extra, param) in [
7961 (json!({"store": true}), "store"),
7962 (
7963 json!({"previous_response_id": "resp_previous"}),
7964 "previous_response_id",
7965 ),
7966 (
7967 json!({"tools": [{"type": "mcp", "server_label": "docs"}]}),
7968 "tools[0].type",
7969 ),
7970 ] {
7971 let mut body = json!({"model": "stub-model", "input": "hello"});
7972 body.as_object_mut()
7973 .unwrap()
7974 .extend(extra.as_object().unwrap().clone());
7975 let response = post_json(router_with_stub("unused"), "/v1/responses", body).await;
7976 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7977 let error = response_json(response).await;
7978 assert_eq!(error["error"]["param"], param, "error: {error}");
7979 }
7980 }
7981
7982 #[tokio::test]
7983 async fn responses_mvp_keeps_chat_completions_route_working() {
7984 let response = post_json(
7985 router_with_stub("chat still works"),
7986 "/v1/chat/completions",
7987 json!({
7988 "model": "stub-model",
7989 "messages": [{"role": "user", "content": "hello"}]
7990 }),
7991 )
7992 .await;
7993 assert_eq!(response.status(), AxumStatusCode::OK);
7994 let body = response_json(response).await;
7995 assert_eq!(body["choices"][0]["message"]["content"], "chat still works");
7996 }
7997
7998 #[test]
7999 fn sanitized_chat_request_body_redacts_user_text_and_secret_metadata() {
8000 let request = chat_request(json!({
8001 "messages": [{"role": "user", "content": "private prompt"}],
8002 "metadata": {"api_key": "should-not-survive"},
8003 "stream": true
8004 }));
8005 let body = sanitized_chat_request_body(&request);
8006 assert_eq!(body["model"], "stub-model");
8007 assert_eq!(body["stream"], true);
8008 assert_eq!(body["messages"][0]["role"], "user");
8009 assert_eq!(body["messages"][0]["content"], "[redacted]");
8010 assert_eq!(body["messages"][0]["content_redacted"], true);
8011 assert_eq!(body["messages"][0]["content_chars"], 14);
8012 assert_eq!(body["metadata"]["api_key"], "[redacted]");
8013 }
8014
8015 #[test]
8016 fn admission_health_prefers_runtime_authority_over_preflight_estimate() {
8017 let engine_status = EngineStatus {
8018 is_ready: true,
8019 loaded_models: Vec::new(),
8020 active_requests: 2,
8021 queued_requests: 1,
8022 memory_usage: MemoryUsage {
8023 total_bytes: 0,
8024 used_bytes: 0,
8025 free_bytes: 0,
8026 gpu_memory_bytes: None,
8027 cpu_memory_bytes: None,
8028 cache_memory_bytes: 0,
8029 utilization_percent: 0.0,
8030 },
8031 uptime_seconds: 0,
8032 last_heartbeat: chrono::Utc::now(),
8033 version: "test".to_owned(),
8034 };
8035 let runtime = ferrum_types::ExecutorAdmissionSnapshot::new(
8036 ferrum_types::ExecutionResourceAuthority::PlanRuntime,
8037 ferrum_types::ExecutorAdmissionLimits::new(32, 4096).unwrap(),
8038 2,
8039 7,
8040 23,
8041 None,
8042 Some(3),
8043 )
8044 .unwrap();
8045 let admission = admission_health_json(
8046 &engine_status,
8047 &EngineMetrics::default(),
8048 &json!({
8049 "admission": {
8050 "effective_max_concurrent": 16,
8051 "scheduler_policy": "continuous"
8052 }
8053 }),
8054 Some(&runtime),
8055 None,
8056 );
8057
8058 assert_eq!(admission["source"], "runtime_executor");
8059 assert_eq!(admission["runtime_snapshot_available"], true);
8060 assert_eq!(admission["resource_authority"], "plan_runtime");
8061 assert_eq!(admission["effective_max_concurrent"], 32);
8062 assert_eq!(admission["maximum_active_sequences"], 32);
8063 assert_eq!(admission["maximum_scheduled_tokens"], 4096);
8064 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8065 assert_eq!(admission["active_sequences"], 30);
8066 assert_eq!(admission["active_prefill"], 7);
8067 assert_eq!(admission["active_decode"], 23);
8068 assert!(admission["current_batch_size"].is_null());
8069 assert_eq!(admission["queue_depth"], 2);
8070 assert_eq!(admission["capacity_blocked_requests"], 3);
8071 }
8072
8073 #[test]
8074 fn admission_health_surfaces_runtime_contract_failure_without_preflight_fallback() {
8075 let engine_status = EngineStatus {
8076 is_ready: true,
8077 loaded_models: Vec::new(),
8078 active_requests: 32,
8079 queued_requests: 1,
8080 memory_usage: MemoryUsage {
8081 total_bytes: 0,
8082 used_bytes: 0,
8083 free_bytes: 0,
8084 gpu_memory_bytes: None,
8085 cpu_memory_bytes: None,
8086 cache_memory_bytes: 0,
8087 utilization_percent: 0.0,
8088 },
8089 uptime_seconds: 0,
8090 last_heartbeat: chrono::Utc::now(),
8091 version: "test".to_owned(),
8092 };
8093 let admission = admission_health_json(
8094 &engine_status,
8095 &EngineMetrics::default(),
8096 &json!({
8097 "admission": {
8098 "effective_max_concurrent": 16,
8099 "scheduler_policy": "continuous"
8100 }
8101 }),
8102 None,
8103 Some("active phase count exceeded the runtime ceiling"),
8104 );
8105
8106 assert_eq!(admission["source"], "runtime_error");
8107 assert_eq!(admission["runtime_snapshot_available"], false);
8108 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8109 assert!(admission["effective_max_concurrent"].is_null());
8110 assert!(admission["queue_depth"].is_null());
8111 assert_eq!(
8112 admission["runtime_contract_error"],
8113 "active phase count exceeded the runtime ceiling"
8114 );
8115 }
8116
8117 #[tokio::test]
8118 async fn route_health_includes_runtime_config_snapshot() {
8119 let response = get(router_with_stub("ok"), "/health").await;
8120 assert_eq!(response.status(), AxumStatusCode::OK);
8121 let body = response_json(response).await;
8122 assert_eq!(body["status"], "healthy");
8123 assert!(body["config"]["entries"].is_array(), "body: {body}");
8124 assert_eq!(body["auto_config"]["schema_version"], 1);
8125 assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
8126 assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
8127 assert_eq!(body["admission"]["schema_version"], 2);
8128 assert!(body["admission"]["effective_max_concurrent"].is_number());
8129 assert!(body["admission"]["queue_depth"].is_number());
8130 assert!(body["admission"]["active_sequences"].is_number());
8131 assert!(body["admission"]["active_prefill"].is_null());
8132 assert!(body["admission"]["active_decode"].is_null());
8133 assert!(body["admission"]["current_batch_size"].is_null());
8134 assert!(body["admission"]["rejected_requests_total"].is_number());
8135 assert!(body["admission"]["failed_requests_total"].is_number());
8136 assert!(body["admission"]["completed_requests_total"].is_number());
8137 assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
8138 assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
8139 assert!(body["scheduler"]["scheduling_time_ms"].is_number());
8140 assert!(body["scheduler"]["model_execution_time_ms"].is_number());
8141 assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
8142 assert!(
8143 body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
8144 "body: {body}"
8145 );
8146 }
8147
8148 #[tokio::test]
8149 async fn route_metrics_includes_admission_counters() {
8150 let response = get(router_with_stub("ok"), "/metrics").await;
8151 assert_eq!(response.status(), AxumStatusCode::OK);
8152 let body = response_text(response).await;
8153 for metric in [
8154 "ferrum_admission_runtime_snapshot_available",
8155 "ferrum_admission_effective_max_concurrent",
8156 "ferrum_admission_queue_depth",
8157 "ferrum_admission_active_sequences",
8158 "ferrum_admission_rejected_requests_total",
8159 "ferrum_admission_failed_requests_total",
8160 "ferrum_admission_completed_requests_total",
8161 ] {
8162 assert!(body.contains(metric), "missing {metric}:\n{body}");
8163 }
8164 for unavailable_metric in [
8165 "ferrum_admission_maximum_active_sequences ",
8166 "ferrum_admission_maximum_scheduled_tokens ",
8167 "ferrum_admission_capacity_blocked_requests ",
8168 "ferrum_admission_active_prefill ",
8169 "ferrum_admission_active_decode ",
8170 "ferrum_admission_current_batch_size ",
8171 ] {
8172 assert!(
8173 !body.contains(unavailable_metric),
8174 "unknown metric was encoded as a real value: {unavailable_metric}\n{body}"
8175 );
8176 }
8177 }
8178
8179 #[tokio::test]
8180 async fn route_health_includes_engine_lora_metrics_snapshot() {
8181 let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
8182 "ok",
8183 json!({
8184 "enabled": true,
8185 "adapter_count": 1,
8186 "active_cache_bindings": 0,
8187 "projection_applications": 7,
8188 "position": "real-inference",
8189 "source": "test-lora",
8190 }),
8191 )))
8192 .with_lora_adapters(
8193 "stub-model",
8194 vec![LoraAdapterModel::new(
8195 "sql",
8196 "stub-model:sql",
8197 "/tmp/sql-adapter",
8198 )],
8199 )
8200 .unwrap()
8201 .build_router();
8202 let response = get(router, "/health").await;
8203 assert_eq!(response.status(), AxumStatusCode::OK);
8204 let body = response_json(response).await;
8205 assert_eq!(body["lora"]["enabled"], true);
8206 assert_eq!(body["lora"]["adapter_count"], 1);
8207 assert_eq!(body["lora"]["projection_applications"], 7);
8208 assert_eq!(body["lora"]["position"], "real-inference");
8209 assert_eq!(body["lora"]["source"], "test-lora");
8210 }
8211
8212 #[tokio::test]
8213 async fn route_health_includes_engine_execution_attribution_snapshot() {
8214 let router = AxumServer::from_llm(Arc::new(StubLlm::with_execution_attribution(
8215 "ok",
8216 json!({
8217 "schema": "ferrum.vnext.provider-attribution.v1",
8218 "attribution_basis": "resolved_plan_and_completed_static_initialization",
8219 "provider_attribution": {
8220 "expected_quant_tensor_count": 400,
8221 "attributed_quant_tensor_count": 400,
8222 "expected_operation_count": 3,
8223 "attributed_operation_count": 3,
8224 "expected_item_count": 403,
8225 "attributed_item_count": 403,
8226 "percent": 100.0,
8227 "denominator_sha256": "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8228 },
8229 "fallback_counts": {"silent": 0, "dense": 0, "legacy": 0}
8230 }),
8231 )))
8232 .build_router();
8233 let response = get(router, "/health").await;
8234 assert_eq!(response.status(), AxumStatusCode::OK);
8235 let body = response_json(response).await;
8236 assert_eq!(
8237 body["execution_attribution"]["provider_attribution"]["expected_item_count"],
8238 403
8239 );
8240 assert_eq!(
8241 body["execution_attribution"]["provider_attribution"]["denominator_sha256"],
8242 "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8243 );
8244 assert_eq!(
8245 body["execution_attribution"]["fallback_counts"],
8246 json!({"silent": 0, "dense": 0, "legacy": 0})
8247 );
8248 }
8249
8250 #[tokio::test]
8251 async fn route_models_lists_loaded_stub_model() {
8252 let response = get(router_with_stub("ok"), "/v1/models").await;
8253 assert_eq!(response.status(), AxumStatusCode::OK);
8254 let body = response_json(response).await;
8255 assert_eq!(body["object"], "list");
8256 let data = body["data"].as_array().expect("models data array");
8257 assert_eq!(data.len(), 1, "body: {body}");
8258 assert_eq!(data[0]["id"], "stub-model");
8259 assert_eq!(data[0]["object"], "model");
8260 assert_eq!(data[0]["owned_by"], "ferrum");
8261 assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
8262 assert_eq!(data[0]["modalities"], json!(["text"]));
8263 assert!(data[0]["permission"].as_array().unwrap().is_empty());
8264 assert!(data[0]["root"].is_null());
8265 assert!(data[0]["parent"].is_null());
8266 }
8267
8268 #[tokio::test]
8269 async fn route_chat_public_alias_maps_to_internal_model_and_is_echoed() {
8270 let engine = Arc::new(CapturingLlm::new());
8271 let registry = ServedModelRegistry::try_new(
8272 "qwen3",
8273 ServedModelKind::Llm,
8274 vec!["served-alias".to_string(), "secondary-alias".to_string()],
8275 vec![],
8276 )
8277 .unwrap();
8278 let router = AxumServer::from_llm(engine.clone())
8279 .with_served_model_registry(registry)
8280 .build_router();
8281 let response = post_json(
8282 router,
8283 "/v1/chat/completions",
8284 json!({
8285 "model": "secondary-alias",
8286 "messages": [{"role": "user", "content": "Say hi"}],
8287 "max_tokens": 8
8288 }),
8289 )
8290 .await;
8291
8292 assert_eq!(response.status(), AxumStatusCode::OK);
8293 let body = response_json(response).await;
8294 assert_eq!(body["model"], "secondary-alias");
8295 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
8296 }
8297
8298 #[tokio::test]
8299 async fn route_models_lists_public_aliases_without_internal_model_id() {
8300 let registry = ServedModelRegistry::try_new(
8301 "qwen3",
8302 ServedModelKind::Llm,
8303 vec!["served-alias".to_string(), "secondary-alias".to_string()],
8304 vec![],
8305 )
8306 .unwrap();
8307 let router = AxumServer::from_llm(Arc::new(CapturingLlm::new()))
8308 .with_served_model_registry(registry)
8309 .build_router();
8310 let body = response_json(get(router, "/v1/models").await).await;
8311 let ids = body["data"]
8312 .as_array()
8313 .unwrap()
8314 .iter()
8315 .map(|entry| entry["id"].as_str().unwrap())
8316 .collect::<Vec<_>>();
8317
8318 assert_eq!(ids, vec!["served-alias", "secondary-alias"]);
8319 assert!(!ids.contains(&"qwen3"));
8320 assert!(body["data"]
8321 .as_array()
8322 .unwrap()
8323 .iter()
8324 .all(|entry| entry["modalities"] == json!(["text"])));
8325 }
8326
8327 #[tokio::test]
8328 async fn route_models_lists_embedding_registry_capabilities() {
8329 let body = response_json(get(router_with_stub_embed(), "/v1/models").await).await;
8330 let data = body["data"].as_array().unwrap();
8331
8332 assert_eq!(data.len(), 1);
8333 assert_eq!(data[0]["id"], "stub-embed");
8334 assert_eq!(data[0]["modalities"], json!(["text", "image"]));
8335 }
8336
8337 #[tokio::test]
8338 async fn route_models_lists_startup_lora_adapters() {
8339 let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
8340 .with_lora_adapters(
8341 "stub-model",
8342 vec![LoraAdapterModel::new(
8343 "sql",
8344 "stub-model:sql",
8345 "/tmp/sql-adapter",
8346 )],
8347 )
8348 .unwrap()
8349 .build_router();
8350 let response = get(router, "/v1/models").await;
8351 assert_eq!(response.status(), AxumStatusCode::OK);
8352 let body = response_json(response).await;
8353 let data = body["data"].as_array().expect("models data array");
8354 let ids: Vec<_> = data
8355 .iter()
8356 .map(|item| item["id"].as_str().unwrap_or_default())
8357 .collect();
8358 assert!(ids.contains(&"stub-model"), "body: {body}");
8359 assert!(ids.contains(&"stub-model:sql"), "body: {body}");
8360 let adapter = data
8361 .iter()
8362 .find(|item| item["id"] == "stub-model:sql")
8363 .expect("adapter model");
8364 assert_eq!(adapter["root"], "stub-model");
8365 assert_eq!(adapter["parent"], "stub-model");
8366 assert_eq!(adapter["modalities"], json!(["text"]));
8367 }
8368
8369 #[tokio::test]
8370 async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
8371 let (router, engine) = router_with_capturing_lora_llm();
8372 let response = post_json(
8373 router,
8374 "/v1/chat/completions",
8375 json!({
8376 "model": "qwen3:sql",
8377 "messages": [{"role": "user", "content": "Say hi"}],
8378 "max_tokens": 8,
8379 "temperature": 0.0
8380 }),
8381 )
8382 .await;
8383 assert_eq!(response.status(), AxumStatusCode::OK);
8384 let body = response_json(response).await;
8385 assert_eq!(body["model"], "qwen3:sql");
8386 let captured = engine.last_request();
8387 assert_eq!(captured.model_id, ModelId::new("qwen3"));
8388 assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
8389 assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
8390 }
8391
8392 #[tokio::test]
8393 async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
8394 let (router, engine) = router_with_capturing_lora_llm();
8395 let response = post_json(
8396 router,
8397 "/v1/chat/completions",
8398 json!({
8399 "model": "qwen3",
8400 "messages": [{"role": "user", "content": "Say hi"}],
8401 "max_tokens": 8,
8402 "temperature": 0.0
8403 }),
8404 )
8405 .await;
8406 assert_eq!(response.status(), AxumStatusCode::OK);
8407 let captured = engine.last_request();
8408 assert_eq!(captured.model_id, ModelId::new("qwen3"));
8409 assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
8410 }
8411
8412 #[tokio::test]
8413 async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
8414 let (router, _) = router_with_capturing_lora_llm();
8415 let response = post_json(
8416 router,
8417 "/v1/chat/completions",
8418 json!({
8419 "model": "qwen3:missing",
8420 "messages": [{"role": "user", "content": "Say hi"}],
8421 "max_tokens": 8
8422 }),
8423 )
8424 .await;
8425 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8426 let body = response_json(response).await;
8427 assert_eq!(body["error"]["type"], "invalid_request_error");
8428 assert_eq!(body["error"]["param"], "model");
8429 assert!(
8430 body["error"]["message"]
8431 .as_str()
8432 .unwrap_or_default()
8433 .contains("unknown model"),
8434 "body: {body}"
8435 );
8436 }
8437
8438 #[tokio::test]
8439 async fn route_chat_unknown_served_model_returns_openai_model_error() {
8440 let engine = Arc::new(CapturingLlm::new());
8441 let router = AxumServer::from_llm(engine.clone()).build_router();
8442 let response = post_json(
8443 router,
8444 "/v1/chat/completions",
8445 json!({
8446 "model": "not-a-loaded-model",
8447 "messages": [{"role": "user", "content": "Say hi"}],
8448 "max_tokens": 8
8449 }),
8450 )
8451 .await;
8452 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8453 let body = response_json(response).await;
8454 assert_eq!(body["error"]["type"], "invalid_request_error");
8455 assert_eq!(body["error"]["param"], "model");
8456 assert!(
8457 body["error"]["message"]
8458 .as_str()
8459 .unwrap_or_default()
8460 .contains("unknown model"),
8461 "body: {body}"
8462 );
8463 assert!(!engine.has_captured_request());
8464 }
8465
8466 #[tokio::test]
8467 async fn route_models_without_engine_returns_empty_list() {
8468 let response = get(router_without_llm(), "/v1/models").await;
8469 assert_eq!(response.status(), AxumStatusCode::OK);
8470 let body = response_json(response).await;
8471 assert_eq!(body["object"], "list");
8472 assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
8473 }
8474
8475 #[tokio::test]
8476 async fn route_basic_chat_contract_uses_stub_engine() {
8477 let response = post_json(
8478 router_with_stub("hello"),
8479 "/v1/chat/completions",
8480 json!({
8481 "model": "stub-model",
8482 "messages": [{"role": "user", "content": "Say hi"}],
8483 "max_tokens": 8,
8484 "temperature": 0.0
8485 }),
8486 )
8487 .await;
8488 assert_eq!(response.status(), AxumStatusCode::OK);
8489 let body = response_json(response).await;
8490 assert_eq!(body["object"], "chat.completion");
8491 assert_eq!(body["choices"][0]["message"]["role"], "assistant");
8492 assert_eq!(body["choices"][0]["message"]["content"], "hello");
8493 assert_eq!(body["usage"]["prompt_tokens"], 7);
8494 assert_eq!(body["usage"]["completion_tokens"], 2);
8495 }
8496
8497 #[tokio::test]
8498 async fn route_chat_serializes_structured_tool_call_response() {
8499 let response = post_json(
8500 router_with_stub_api_response(
8501 "",
8502 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8503 message: ferrum_types::ApiChatMessage {
8504 role: ferrum_types::ApiMessageRole::Assistant,
8505 content: String::new(),
8506 name: None,
8507 tool_calls: vec![ferrum_types::ApiToolCall {
8508 id: "call_1".to_string(),
8509 tool_type: "function".to_string(),
8510 function: ferrum_types::ApiFunctionCall {
8511 name: "weather".to_string(),
8512 arguments: "{\"city\":\"Paris\"}".to_string(),
8513 },
8514 }],
8515 tool_call_id: None,
8516 function_call: None,
8517 },
8518 finish_reason: Some("tool_calls".to_string()),
8519 }),
8520 ),
8521 "/v1/chat/completions",
8522 json!({
8523 "model": "stub-model",
8524 "messages": [{"role": "user", "content": "Use the weather tool."}],
8525 "tools": [{
8526 "type": "function",
8527 "function": {
8528 "name": "weather",
8529 "parameters": {
8530 "type": "object",
8531 "properties": {"city": {"type": "string"}},
8532 "required": ["city"]
8533 }
8534 }
8535 }],
8536 "tool_choice": "auto"
8537 }),
8538 )
8539 .await;
8540 assert_eq!(response.status(), AxumStatusCode::OK);
8541 let body = response_json(response).await;
8542 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8543 assert_eq!(
8544 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8545 "weather"
8546 );
8547 assert_eq!(
8548 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8549 "{\"city\":\"Paris\"}"
8550 );
8551 }
8552
8553 #[tokio::test]
8554 async fn route_chat_preserves_length_over_structured_tool_response() {
8555 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
8556 let response = post_json(
8557 router_with_stub_api_response_and_finish_reason(
8558 generated,
8559 weather_tool_api_response(),
8560 FinishReason::Length,
8561 ),
8562 "/v1/chat/completions",
8563 json!({
8564 "model": "stub-model",
8565 "messages": [{"role": "user", "content": "Use the weather tool."}],
8566 "tools": [{
8567 "type": "function",
8568 "function": {"name": "weather", "parameters": {"type": "object"}}
8569 }],
8570 "tool_choice": "auto"
8571 }),
8572 )
8573 .await;
8574 assert_eq!(response.status(), AxumStatusCode::OK);
8575 let body = response_json(response).await;
8576 assert_eq!(body["choices"][0]["finish_reason"], "length");
8577 assert_eq!(body["choices"][0]["message"]["content"], generated);
8578 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
8579 }
8580
8581 #[tokio::test]
8582 async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
8583 let response = post_json(
8584 router_with_stub(
8585 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
8586 ),
8587 "/v1/chat/completions",
8588 json!({
8589 "model": "stub-model",
8590 "messages": [{"role": "user", "content": "Use the weather tool."}],
8591 "tools": [{
8592 "type": "function",
8593 "function": {
8594 "name": "weather",
8595 "parameters": {
8596 "type": "object",
8597 "properties": {"city": {"type": "string"}},
8598 "required": ["city"]
8599 }
8600 }
8601 }],
8602 "tool_choice": "auto"
8603 }),
8604 )
8605 .await;
8606 assert_eq!(response.status(), AxumStatusCode::OK);
8607 let body = response_json(response).await;
8608 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8609 assert_eq!(body["choices"][0]["message"]["content"], "");
8610 assert_eq!(
8611 body["choices"][0]["message"]["tool_calls"][0]["id"],
8612 "call_1"
8613 );
8614 assert_eq!(
8615 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8616 "weather"
8617 );
8618 assert_eq!(
8619 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8620 "{\"city\":\"Paris\"}"
8621 );
8622 }
8623
8624 #[tokio::test]
8625 async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
8626 let response = post_json(
8627 router_with_stub(
8628 r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
8629 ),
8630 "/v1/chat/completions",
8631 json!({
8632 "model": "stub-model",
8633 "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
8634 "tools": [{
8635 "type": "function",
8636 "function": {
8637 "name": "get_weather",
8638 "parameters": {
8639 "type": "object",
8640 "properties": {
8641 "city": {"type": "string"},
8642 "unit": {"type": "string", "enum": ["c", "f"]}
8643 },
8644 "required": ["city"]
8645 }
8646 }
8647 }],
8648 "tool_choice": "auto"
8649 }),
8650 )
8651 .await;
8652 assert_eq!(response.status(), AxumStatusCode::OK);
8653 let body = response_json(response).await;
8654 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8655 assert_eq!(body["choices"][0]["message"]["content"], "");
8656 assert_eq!(
8657 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8658 "get_weather"
8659 );
8660 assert_eq!(
8661 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8662 "{\"city\":\"北京\",\"unit\":\"c\"}"
8663 );
8664 }
8665
8666 #[tokio::test]
8667 async fn route_chat_uses_template_tool_protocol_for_function_parameter_xml() {
8668 let template = ModelChatTemplate::new(
8669 "{% 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 %}",
8670 "function-parameter-xml-template",
8671 );
8672 let response = post_json(
8673 router_with_stub_and_template(
8674 "<tool_call>\n<function=get_weather>\n<parameter=city>\n北京\n</parameter>\n<parameter=unit>\ncelsius\n</parameter>\n</function>\n</tool_call>",
8675 template,
8676 ),
8677 "/v1/chat/completions",
8678 json!({
8679 "model": "stub-model",
8680 "messages": [{"role": "user", "content": "请调用 get_weather 查询北京天气。"}],
8681 "tools": [{
8682 "type": "function",
8683 "function": {
8684 "name": "get_weather",
8685 "parameters": {
8686 "type": "object",
8687 "properties": {
8688 "city": {"type": "string"},
8689 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
8690 },
8691 "required": ["city"]
8692 }
8693 }
8694 }]
8695 }),
8696 )
8697 .await;
8698
8699 assert_eq!(response.status(), AxumStatusCode::OK);
8700 let body = response_json(response).await;
8701 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8702 assert_eq!(
8703 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8704 "get_weather"
8705 );
8706 assert_eq!(
8707 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8708 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
8709 );
8710 }
8711
8712 fn xml_object_argument_tool_request(stream: bool) -> Value {
8713 json!({
8714 "model": "stub-model",
8715 "messages": [{"role": "user", "content": "Weather in Berlin with a forecast."}],
8716 "stream": stream,
8717 "tools": [{
8718 "type": "function",
8719 "function": {
8720 "name": "get_weather",
8721 "parameters": {
8722 "type": "object",
8723 "$defs": {
8724 "WeatherOptions": {
8725 "type": "object",
8726 "properties": {
8727 "unit": {"type": "string"},
8728 "include_forecast": {"type": "boolean"}
8729 },
8730 "required": ["unit", "include_forecast"],
8731 "additionalProperties": false
8732 }
8733 },
8734 "properties": {
8735 "city": {"type": "string"},
8736 "options": {"$ref": "#/$defs/WeatherOptions"}
8737 },
8738 "required": ["city", "options"],
8739 "additionalProperties": false
8740 }
8741 }
8742 }]
8743 })
8744 }
8745
8746 #[tokio::test]
8747 async fn route_chat_decodes_xml_object_argument_through_local_schema_ref() {
8748 let template = ModelChatTemplate::new(
8749 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
8750 "function-parameter-xml-template",
8751 );
8752 let response = post_json(
8753 router_with_stub_and_template(
8754 concat!(
8755 "<tool_call>\n",
8756 "<function=get_weather>\n",
8757 "<parameter=city>\nBerlin\n</parameter>\n",
8758 "<parameter=options>\n",
8759 "{\"unit\":\"celsius\",\"include_forecast\":true}\n",
8760 "</parameter>\n",
8761 "</function>\n",
8762 "</tool_call>",
8763 ),
8764 template,
8765 ),
8766 "/v1/chat/completions",
8767 xml_object_argument_tool_request(false),
8768 )
8769 .await;
8770
8771 assert_eq!(response.status(), AxumStatusCode::OK);
8772 let body = response_json(response).await;
8773 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8774 let arguments = body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
8775 .as_str()
8776 .and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
8777 .expect("tool arguments must contain one-decode structured JSON");
8778 assert_eq!(arguments["city"], json!("Berlin"));
8779 assert_eq!(
8780 arguments["options"],
8781 json!({"unit": "celsius", "include_forecast": true})
8782 );
8783 }
8784
8785 #[tokio::test]
8786 async fn route_chat_rejects_malformed_native_xml_object_argument() {
8787 let template = ModelChatTemplate::new(
8788 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
8789 "function-parameter-xml-template",
8790 );
8791 let response = post_json(
8792 router_with_stub_and_template(
8793 concat!(
8794 "<tool_call><function=get_weather>",
8795 "<parameter=city>Berlin</parameter>",
8796 "<parameter=options>{\"unit\":\"celsius\",</parameter>",
8797 "</function></tool_call>",
8798 ),
8799 template,
8800 ),
8801 "/v1/chat/completions",
8802 xml_object_argument_tool_request(false),
8803 )
8804 .await;
8805
8806 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
8807 let body = response_json(response).await;
8808 assert_eq!(body["error"]["type"], "internal_server_error");
8809 assert!(
8810 body["error"]["message"]
8811 .as_str()
8812 .is_some_and(|message| message.contains("did not satisfy its schema")),
8813 "body: {body}"
8814 );
8815 }
8816
8817 #[tokio::test]
8818 async fn route_streaming_chat_rejects_malformed_native_xml_before_tool_delta() {
8819 let template = ModelChatTemplate::new(
8820 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
8821 "function-parameter-xml-template",
8822 );
8823 let response = post_json(
8824 router_with_stub_and_template(
8825 concat!(
8826 "<tool_call><function=get_weather>",
8827 "<parameter=city>Berlin</parameter>",
8828 "<parameter=options>{\"unit\":\"celsius\",</parameter>",
8829 "</function></tool_call>",
8830 ),
8831 template,
8832 ),
8833 "/v1/chat/completions",
8834 xml_object_argument_tool_request(true),
8835 )
8836 .await;
8837
8838 assert_eq!(response.status(), AxumStatusCode::OK);
8839 let body = response_text(response).await;
8840 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
8841 assert!(
8842 body.contains(r#""error":{"#) && body.contains("did not satisfy its schema"),
8843 "stream must return a controlled schema error: {body}"
8844 );
8845 assert!(
8846 !body.contains(r#""tool_calls":[{"#),
8847 "invalid native arguments must not leak a tool delta: {body}"
8848 );
8849 }
8850
8851 #[tokio::test]
8852 async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
8853 let response = post_json(
8854 router_with_stub(
8855 "kaza\n\
8856 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
8857 </think>\n\
8858 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
8859 {\"temperature\":25,\"condition\":\"晴\"}",
8860 ),
8861 "/v1/chat/completions",
8862 json!({
8863 "model": "stub-model",
8864 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
8865 "tools": [{
8866 "type": "function",
8867 "function": {
8868 "name": "get_weather",
8869 "parameters": {
8870 "type": "object",
8871 "properties": {
8872 "city": {"type": "string"},
8873 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
8874 },
8875 "required": ["city"]
8876 }
8877 }
8878 }]
8879 }),
8880 )
8881 .await;
8882 assert_eq!(response.status(), AxumStatusCode::OK);
8883 let body = response_json(response).await;
8884 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8885 assert_eq!(body["choices"][0]["message"]["content"], "");
8886 assert_eq!(
8887 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8888 "get_weather"
8889 );
8890 assert_eq!(
8891 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8892 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
8893 );
8894 }
8895
8896 #[tokio::test]
8897 async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
8898 let response = post_json(
8899 router_with_stub(
8900 "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
8901 </think>\n\
8902 {\"name\":\"get_weather\",\"arguments\":{}}",
8903 ),
8904 "/v1/chat/completions",
8905 json!({
8906 "model": "stub-model",
8907 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
8908 "tools": [{
8909 "type": "function",
8910 "function": {
8911 "name": "get_weather",
8912 "parameters": {
8913 "type": "object",
8914 "properties": {
8915 "city": {"type": "string"},
8916 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
8917 },
8918 "required": ["city"]
8919 }
8920 }
8921 }]
8922 }),
8923 )
8924 .await;
8925 assert_eq!(response.status(), AxumStatusCode::OK);
8926 let body = response_json(response).await;
8927 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8928 assert_eq!(
8929 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8930 "get_weather"
8931 );
8932 assert_eq!(
8933 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8934 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
8935 );
8936 }
8937
8938 #[tokio::test]
8939 async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
8940 let response = post_json(
8941 router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
8942 "/v1/chat/completions",
8943 json!({
8944 "model": "stub-model",
8945 "messages": [{"role": "user", "content": "Use the selected tool."}],
8946 "tools": [
8947 {
8948 "type": "function",
8949 "function": {"name": "weather", "parameters": {"type": "object"}}
8950 },
8951 {
8952 "type": "function",
8953 "function": {"name": "calendar", "parameters": {"type": "object"}}
8954 }
8955 ],
8956 "tool_choice": {
8957 "type": "function",
8958 "function": {"name": "weather"}
8959 }
8960 }),
8961 )
8962 .await;
8963 assert_eq!(response.status(), AxumStatusCode::OK);
8964 let body = response_json(response).await;
8965 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8966 assert_eq!(
8967 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8968 "weather"
8969 );
8970
8971 let response = post_json(
8972 router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
8973 "/v1/chat/completions",
8974 json!({
8975 "model": "stub-model",
8976 "messages": [{"role": "user", "content": "Use the selected tool."}],
8977 "tools": [
8978 {
8979 "type": "function",
8980 "function": {"name": "weather", "parameters": {"type": "object"}}
8981 },
8982 {
8983 "type": "function",
8984 "function": {"name": "calendar", "parameters": {"type": "object"}}
8985 }
8986 ],
8987 "tool_choice": {
8988 "type": "function",
8989 "function": {"name": "weather"}
8990 }
8991 }),
8992 )
8993 .await;
8994 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8995 let body = response_json(response).await;
8996 assert_eq!(body["error"]["param"], "tool_choice");
8997 assert_eq!(body["error"]["type"], "invalid_request_error");
8998 }
8999
9000 #[tokio::test]
9001 async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
9002 let response = post_json(
9003 router_with_stub(r#"{"city":"Paris"}"#),
9004 "/v1/chat/completions",
9005 json!({
9006 "model": "stub-model",
9007 "messages": [{"role": "user", "content": "Use the selected tool."}],
9008 "tools": [{
9009 "type": "function",
9010 "function": {
9011 "name": "weather",
9012 "parameters": {
9013 "type": "object",
9014 "properties": {"city": {"type": "string"}},
9015 "required": ["city"]
9016 }
9017 }
9018 }],
9019 "tool_choice": {
9020 "type": "function",
9021 "function": {"name": "weather"}
9022 }
9023 }),
9024 )
9025 .await;
9026 assert_eq!(response.status(), AxumStatusCode::OK);
9027 let body = response_json(response).await;
9028 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9029 assert_eq!(body["choices"][0]["message"]["content"], "");
9030 assert_eq!(
9031 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9032 "weather"
9033 );
9034 assert_eq!(
9035 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9036 "{\"city\":\"Paris\"}"
9037 );
9038 }
9039
9040 #[tokio::test]
9041 async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
9042 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9043 let response = post_json(
9044 router_with_stub(generated),
9045 "/v1/chat/completions",
9046 json!({
9047 "model": "stub-model",
9048 "messages": [{"role": "user", "content": "Do not use tools."}],
9049 "tools": [{
9050 "type": "function",
9051 "function": {"name": "weather", "parameters": {"type": "object"}}
9052 }],
9053 "tool_choice": "none"
9054 }),
9055 )
9056 .await;
9057 assert_eq!(response.status(), AxumStatusCode::OK);
9058 let body = response_json(response).await;
9059 assert_eq!(body["choices"][0]["finish_reason"], "stop");
9060 assert_eq!(body["choices"][0]["message"]["content"], generated);
9061 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
9062 }
9063
9064 #[tokio::test]
9065 async fn route_chat_tool_choice_required_wraps_generated_arguments() {
9066 let response = post_json(
9067 router_with_stub(r#"{"city":"Paris"}"#),
9068 "/v1/chat/completions",
9069 json!({
9070 "model": "stub-model",
9071 "messages": [{"role": "user", "content": "Use a tool."}],
9072 "tools": [{
9073 "type": "function",
9074 "function": {
9075 "name": "weather",
9076 "parameters": {
9077 "type": "object",
9078 "properties": {"city": {"type": "string"}},
9079 "required": ["city"]
9080 }
9081 }
9082 }],
9083 "tool_choice": "required"
9084 }),
9085 )
9086 .await;
9087 assert_eq!(response.status(), AxumStatusCode::OK);
9088 let body = response_json(response).await;
9089 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9090 assert_eq!(body["choices"][0]["message"]["content"], "");
9091 assert_eq!(
9092 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9093 "weather"
9094 );
9095 assert_eq!(
9096 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9097 "{\"city\":\"Paris\"}"
9098 );
9099 }
9100
9101 fn required_tool_with_strict_response_format_request(stream: bool) -> Value {
9102 json!({
9103 "model": "stub-model",
9104 "messages": [{"role": "user", "content": "Use the weather tool."}],
9105 "stream": stream,
9106 "stream_options": stream.then_some(json!({"include_usage": true})),
9107 "tools": [{
9108 "type": "function",
9109 "function": {
9110 "name": "weather",
9111 "parameters": {
9112 "type": "object",
9113 "properties": {"city": {"type": "string", "const": "Paris"}},
9114 "required": ["city"],
9115 "additionalProperties": false
9116 }
9117 }
9118 }],
9119 "tool_choice": "required",
9120 "response_format": {
9121 "type": "json_schema",
9122 "json_schema": {
9123 "name": "content_answer",
9124 "strict": true,
9125 "schema": {
9126 "type": "object",
9127 "properties": {"answer": {"type": "string", "const": "IGNORED"}},
9128 "required": ["answer"],
9129 "additionalProperties": false
9130 }
9131 }
9132 }
9133 })
9134 }
9135
9136 #[tokio::test]
9137 async fn route_chat_required_tool_takes_priority_over_strict_response_format() {
9138 let response = post_json(
9139 router_with_stub(r#"{"city":"Paris"}"#),
9140 "/v1/chat/completions",
9141 required_tool_with_strict_response_format_request(false),
9142 )
9143 .await;
9144 assert_eq!(response.status(), AxumStatusCode::OK);
9145 let body = response_json(response).await;
9146 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9147 assert_eq!(body["choices"][0]["message"]["content"], "");
9148 assert_eq!(
9149 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9150 "weather"
9151 );
9152 assert_eq!(
9153 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9154 r#"{"city":"Paris"}"#
9155 );
9156 }
9157
9158 #[tokio::test]
9159 async fn route_chat_required_tool_rejects_arguments_that_violate_const_schema() {
9160 let response = post_json(
9161 router_with_stub(r#"{"city":"London"}"#),
9162 "/v1/chat/completions",
9163 required_tool_with_strict_response_format_request(false),
9164 )
9165 .await;
9166 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9167 let body = response_json(response).await;
9168 assert_eq!(body["error"]["type"], "internal_server_error");
9169 assert!(
9170 body["error"]["message"]
9171 .as_str()
9172 .is_some_and(|message| message.contains("did not satisfy its schema")),
9173 "body: {body}"
9174 );
9175 }
9176
9177 #[tokio::test]
9178 async fn route_streaming_required_tool_takes_priority_over_strict_response_format() {
9179 let response = post_json(
9180 router_with_stub(r#"{"city":"Paris"}"#),
9181 "/v1/chat/completions",
9182 required_tool_with_strict_response_format_request(true),
9183 )
9184 .await;
9185 assert_eq!(response.status(), AxumStatusCode::OK);
9186 let body = response_text(response).await;
9187 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9188 assert!(
9189 body.contains(r#""finish_reason":"tool_calls""#),
9190 "tool priority must finish with tool_calls: {body}"
9191 );
9192 assert!(
9193 body.contains(r#""name":"weather""#)
9194 && body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9195 "stream must carry the reconstructed tool call: {body}"
9196 );
9197 assert_eq!(
9198 body.matches(r#""usage":{"#).count(),
9199 1,
9200 "stream must carry exactly one usage row: {body}"
9201 );
9202 assert!(
9203 !body.contains("strict json_schema") && !body.contains("invalid JSON"),
9204 "dormant content schema must not reject a required tool call: {body}"
9205 );
9206 }
9207
9208 #[tokio::test]
9209 async fn dropping_buffered_http_response_drops_the_engine_stream() {
9210 let stream_dropped = Arc::new(Notify::new());
9211 let response = post_json(
9212 AxumServer::from_llm(Arc::new(StubLlm::with_pending_stream(Arc::clone(
9213 &stream_dropped,
9214 ))))
9215 .build_router(),
9216 "/v1/chat/completions",
9217 required_tool_with_strict_response_format_request(true),
9218 )
9219 .await;
9220 assert_eq!(response.status(), AxumStatusCode::OK);
9221
9222 drop(response);
9223 tokio::time::timeout(std::time::Duration::from_secs(1), stream_dropped.notified())
9224 .await
9225 .expect("client disconnect must stop a buffered structured stream promptly");
9226 }
9227
9228 #[tokio::test]
9229 async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
9230 let response = post_json(
9231 router_with_stub("plain answer"),
9232 "/v1/chat/completions",
9233 json!({
9234 "model": "stub-model",
9235 "messages": [{"role": "user", "content": "Use a tool."}],
9236 "tools": [{
9237 "type": "function",
9238 "function": {"name": "weather", "parameters": {"type": "object"}}
9239 }],
9240 "tool_choice": "required"
9241 }),
9242 )
9243 .await;
9244 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9245 let body = response_json(response).await;
9246 assert_eq!(body["error"]["type"], "invalid_request_error");
9247 assert_eq!(body["error"]["param"], "tool_choice");
9248 assert!(
9249 body["error"]["message"]
9250 .as_str()
9251 .is_some_and(|message| message.contains("required tool_choice")),
9252 "body: {body}"
9253 );
9254 }
9255
9256 #[tokio::test]
9257 async fn route_streaming_chat_serializes_generated_tool_call_delta() {
9258 let response = post_json(
9259 router_with_stub(
9260 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
9261 ),
9262 "/v1/chat/completions",
9263 json!({
9264 "model": "stub-model",
9265 "messages": [{"role": "user", "content": "Use the weather tool."}],
9266 "stream": true,
9267 "tools": [{
9268 "type": "function",
9269 "function": {
9270 "name": "weather",
9271 "parameters": {
9272 "type": "object",
9273 "properties": {"city": {"type": "string"}},
9274 "required": ["city"]
9275 }
9276 }
9277 }],
9278 "tool_choice": "auto"
9279 }),
9280 )
9281 .await;
9282 assert_eq!(response.status(), AxumStatusCode::OK);
9283 let body = response_text(response).await;
9284 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9285 assert!(
9286 body.contains(r#""finish_reason":"tool_calls""#),
9287 "stream should finish with tool_calls: {body}"
9288 );
9289 assert!(
9290 body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
9291 "stream should emit OpenAI tool_calls delta with index: {body}"
9292 );
9293 assert!(
9294 body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9295 "tool arguments should be serialized as JSON string: {body}"
9296 );
9297 assert!(
9298 !body.contains(r#""content":"{\"tool_calls\""#),
9299 "raw tool-call JSON should not be streamed as assistant content: {body}"
9300 );
9301 }
9302
9303 #[tokio::test]
9304 async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
9305 let response = post_json(
9306 router_with_stub(
9307 r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
9308 ),
9309 "/v1/chat/completions",
9310 json!({
9311 "model": "stub-model",
9312 "messages": [{"role": "user", "content": "深圳天气?"}],
9313 "stream": true,
9314 "tools": [{
9315 "type": "function",
9316 "function": {
9317 "name": "get_weather",
9318 "parameters": {
9319 "type": "object",
9320 "properties": {
9321 "city": {"type": "string"},
9322 "unit": {"type": "string", "enum": ["c", "f"]}
9323 },
9324 "required": ["city"]
9325 }
9326 }
9327 }],
9328 "tool_choice": "auto"
9329 }),
9330 )
9331 .await;
9332 assert_eq!(response.status(), AxumStatusCode::OK);
9333 let body = response_text(response).await;
9334 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9335 assert!(
9336 body.contains(r#""finish_reason":"tool_calls""#),
9337 "stream should finish with tool_calls: {body}"
9338 );
9339 assert!(
9340 body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
9341 "stream should emit parsed Qwen3 function parameters as tool args: {body}"
9342 );
9343 assert!(
9344 !body.contains(r#""content":"{\"function\""#),
9345 "raw Qwen3 tool JSON should not leak as assistant content: {body}"
9346 );
9347 }
9348
9349 #[tokio::test]
9350 async fn route_streaming_chat_preserves_opencode_edit_xml_whitespace() {
9351 let template = ModelChatTemplate::new(
9352 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9353 "function-parameter-xml-template",
9354 );
9355 let generated = concat!(
9356 "<tool_call>\n",
9357 "<function=edit>\n",
9358 "<parameter=filePath>\n",
9359 "/workspace/src/main.rs\n",
9360 "</parameter>\n",
9361 "<parameter=oldString>\n",
9362 " if x:\n",
9363 " return 1\n",
9364 "\n",
9365 "</parameter>\n",
9366 "<parameter=newString>\n",
9367 " if x:\n",
9368 " return 2\n",
9369 "\n",
9370 "</parameter>\n",
9371 "<parameter=replaceAll>\n",
9372 "true\n",
9373 "</parameter>\n",
9374 "</function>\n",
9375 "</tool_call>",
9376 );
9377 let response = post_json(
9378 router_with_stub_and_template(generated, template),
9379 "/v1/chat/completions",
9380 json!({
9381 "model": "stub-model",
9382 "messages": [{"role": "user", "content": "Replace the code."}],
9383 "stream": true,
9384 "tools": [{
9385 "type": "function",
9386 "function": {
9387 "name": "edit",
9388 "parameters": {
9389 "type": "object",
9390 "properties": {
9391 "filePath": {"type": "string"},
9392 "oldString": {"type": "string"},
9393 "newString": {"type": "string"},
9394 "replaceAll": {"type": "boolean"}
9395 },
9396 "required": ["filePath", "oldString", "newString"]
9397 }
9398 }
9399 }]
9400 }),
9401 )
9402 .await;
9403
9404 assert_eq!(response.status(), AxumStatusCode::OK);
9405 let body = response_text(response).await;
9406 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9407 assert!(
9408 body.contains(r#"\"oldString\":\" if x:\\n return 1\\n\""#),
9409 "stream must preserve exact code whitespace in tool arguments: {body}"
9410 );
9411 assert!(
9412 body.contains(r#"\"replaceAll\":true"#),
9413 "stream must preserve the boolean tool argument type: {body}"
9414 );
9415 }
9416
9417 #[tokio::test]
9418 async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
9419 let request = |generated: &'static str| {
9420 post_json(
9421 router_with_stub(generated),
9422 "/v1/chat/completions",
9423 json!({
9424 "model": "stub-model",
9425 "messages": [{"role": "user", "content": "Use the selected tool."}],
9426 "stream": true,
9427 "tools": [
9428 {
9429 "type": "function",
9430 "function": {"name": "weather", "parameters": {"type": "object"}}
9431 },
9432 {
9433 "type": "function",
9434 "function": {"name": "calendar", "parameters": {"type": "object"}}
9435 }
9436 ],
9437 "tool_choice": {
9438 "type": "function",
9439 "function": {"name": "weather"}
9440 }
9441 }),
9442 )
9443 };
9444
9445 let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
9446 assert_eq!(response.status(), AxumStatusCode::OK);
9447 let body = response_text(response).await;
9448 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9449 assert!(
9450 body.contains(r#""finish_reason":"tool_calls""#),
9451 "selected tool should finish with tool_calls: {body}"
9452 );
9453 assert!(
9454 body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
9455 "selected tool should stream as tool_calls delta: {body}"
9456 );
9457
9458 let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
9459 assert_eq!(response.status(), AxumStatusCode::OK);
9460 let body = response_text(response).await;
9461 assert!(
9462 body.contains(
9463 r#""error":{"message":"model output did not satisfy required tool_choice""#
9464 ),
9465 "selected-tool stream should reject unselected tool output: {body}"
9466 );
9467 assert!(
9468 !body.contains(r#""finish_reason":"tool_calls""#),
9469 "unselected tool JSON must not become tool_calls: {body}"
9470 );
9471 }
9472
9473 #[tokio::test]
9474 async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
9475 let response = post_json(
9476 router_with_stub_api_response(
9477 "raw text that should not stream",
9478 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
9479 message: ferrum_types::ApiChatMessage {
9480 role: ferrum_types::ApiMessageRole::Assistant,
9481 content: String::new(),
9482 name: None,
9483 tool_calls: vec![ferrum_types::ApiToolCall {
9484 id: "call_1".to_string(),
9485 tool_type: "function".to_string(),
9486 function: ferrum_types::ApiFunctionCall {
9487 name: "weather".to_string(),
9488 arguments: "{\"city\":\"Paris\"}".to_string(),
9489 },
9490 }],
9491 tool_call_id: None,
9492 function_call: None,
9493 },
9494 finish_reason: Some("tool_calls".to_string()),
9495 }),
9496 ),
9497 "/v1/chat/completions",
9498 json!({
9499 "model": "stub-model",
9500 "messages": [{"role": "user", "content": "Use the weather tool."}],
9501 "stream": true,
9502 "tools": [{
9503 "type": "function",
9504 "function": {"name": "weather", "parameters": {"type": "object"}}
9505 }],
9506 "tool_choice": "auto"
9507 }),
9508 )
9509 .await;
9510 assert_eq!(response.status(), AxumStatusCode::OK);
9511 let body = response_text(response).await;
9512 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9513 assert!(
9514 body.contains(r#""finish_reason":"tool_calls""#),
9515 "stream should finish with tool_calls: {body}"
9516 );
9517 assert!(
9518 body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
9519 "stream should emit tool_calls from chunk api_response: {body}"
9520 );
9521 assert!(
9522 !body.contains("raw text that should not stream"),
9523 "structured api_response should suppress raw generated text in tool-call stream: {body}"
9524 );
9525 }
9526
9527 #[tokio::test]
9528 async fn route_streaming_chat_preserves_length_over_structured_tool_response() {
9529 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9530 let response = post_json(
9531 router_with_stub_api_response_and_finish_reason(
9532 generated,
9533 weather_tool_api_response(),
9534 FinishReason::Length,
9535 ),
9536 "/v1/chat/completions",
9537 json!({
9538 "model": "stub-model",
9539 "messages": [{"role": "user", "content": "Use the weather tool."}],
9540 "stream": true,
9541 "tools": [{
9542 "type": "function",
9543 "function": {"name": "weather", "parameters": {"type": "object"}}
9544 }],
9545 "tool_choice": "auto"
9546 }),
9547 )
9548 .await;
9549 assert_eq!(response.status(), AxumStatusCode::OK);
9550 let body = response_text(response).await;
9551 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9552 assert!(
9553 body.contains(r#""finish_reason":"length""#),
9554 "stream must preserve the engine terminal reason: {body}"
9555 );
9556 assert!(
9557 !body.contains(r#""finish_reason":"tool_calls""#),
9558 "length must not be relabeled as tool_calls: {body}"
9559 );
9560 }
9561
9562 #[tokio::test]
9563 async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
9564 let response = post_json(
9565 router_with_stub("plain answer"),
9566 "/v1/chat/completions",
9567 json!({
9568 "model": "stub-model",
9569 "messages": [{"role": "user", "content": "Use a tool."}],
9570 "stream": true,
9571 "tools": [{
9572 "type": "function",
9573 "function": {"name": "weather", "parameters": {"type": "object"}}
9574 }],
9575 "tool_choice": "required"
9576 }),
9577 )
9578 .await;
9579 assert_eq!(response.status(), AxumStatusCode::OK);
9580 let body = response_text(response).await;
9581 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9582 assert!(
9583 body.contains(
9584 r#""error":{"message":"model output did not satisfy required tool_choice""#
9585 ),
9586 "stream should emit OpenAI error envelope: {body}"
9587 );
9588 assert!(
9589 body.contains(r#""type":"invalid_request_error""#),
9590 "stream should use invalid_request_error: {body}"
9591 );
9592 assert!(
9593 body.contains(r#""param":"tool_choice""#),
9594 "stream should include tool_choice param: {body}"
9595 );
9596 assert!(
9597 !body.contains(r#""content":"plain answer""#),
9598 "required stream must not leak invalid content before validation: {body}"
9599 );
9600 }
9601
9602 #[tokio::test]
9603 async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
9604 let response = post_json(
9605 router_with_stub("plain answer"),
9606 "/v1/chat/completions",
9607 json!({
9608 "model": "stub-model",
9609 "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
9610 "stream": true,
9611 "tools": [{
9612 "type": "function",
9613 "function": {"name": "weather", "parameters": {"type": "object"}}
9614 }],
9615 "tool_choice": "auto"
9616 }),
9617 )
9618 .await;
9619 assert_eq!(response.status(), AxumStatusCode::OK);
9620 let body = response_text(response).await;
9621 assert!(
9622 body.contains(r#""content":"plain answer""#),
9623 "plain content should still stream when no tool call is generated: {body}"
9624 );
9625 assert!(
9626 body.contains(r#""finish_reason":"stop""#),
9627 "plain content should keep normal finish reason: {body}"
9628 );
9629 assert!(
9630 !body.contains(r#""tool_calls""#),
9631 "fallback content should not synthesize tool_calls: {body}"
9632 );
9633 }
9634
9635 #[tokio::test]
9636 async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
9637 let response = post_json(
9638 router_with_stub(
9639 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
9640 ),
9641 "/v1/chat/completions",
9642 json!({
9643 "model": "stub-model",
9644 "messages": [{"role": "user", "content": "Use the weather function."}],
9645 "stream": true,
9646 "functions": [{
9647 "name": "weather",
9648 "parameters": {
9649 "type": "object",
9650 "properties": {"city": {"type": "string"}},
9651 "required": ["city"]
9652 }
9653 }],
9654 "function_call": "auto"
9655 }),
9656 )
9657 .await;
9658 assert_eq!(response.status(), AxumStatusCode::OK);
9659 let body = response_text(response).await;
9660 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9661 assert!(
9662 body.contains(r#""finish_reason":"function_call""#),
9663 "stream should finish with function_call: {body}"
9664 );
9665 assert!(
9666 body.contains(
9667 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
9668 ),
9669 "stream should emit OpenAI legacy function_call delta: {body}"
9670 );
9671 assert!(
9672 !body.contains(r#""content":"{\"function_call\""#),
9673 "raw function-call JSON should not be streamed as assistant content: {body}"
9674 );
9675 }
9676
9677 #[tokio::test]
9678 async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
9679 let request = |generated: &'static str| {
9680 post_json(
9681 router_with_stub(generated),
9682 "/v1/chat/completions",
9683 json!({
9684 "model": "stub-model",
9685 "messages": [{"role": "user", "content": "Use the selected function."}],
9686 "stream": true,
9687 "functions": [
9688 {"name": "weather", "parameters": {"type": "object"}},
9689 {"name": "calendar", "parameters": {"type": "object"}}
9690 ],
9691 "function_call": {"name": "weather"}
9692 }),
9693 )
9694 };
9695
9696 let response =
9697 request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
9698 assert_eq!(response.status(), AxumStatusCode::OK);
9699 let body = response_text(response).await;
9700 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9701 assert!(
9702 body.contains(r#""finish_reason":"function_call""#),
9703 "selected function should finish with function_call: {body}"
9704 );
9705 assert!(
9706 body.contains(
9707 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
9708 ),
9709 "selected function should stream as function_call delta: {body}"
9710 );
9711
9712 let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
9713 assert_eq!(response.status(), AxumStatusCode::OK);
9714 let body = response_text(response).await;
9715 assert!(
9716 body.contains(
9717 r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
9718 ),
9719 "unselected function JSON should stream as ordinary content: {body}"
9720 );
9721 assert!(
9722 body.contains(r#""finish_reason":"stop""#),
9723 "unselected function JSON should keep normal stop finish: {body}"
9724 );
9725 assert!(
9726 !body.contains(r#""finish_reason":"function_call""#),
9727 "unselected function JSON must not become function_call: {body}"
9728 );
9729 }
9730
9731 #[tokio::test]
9732 async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
9733 let response = post_json(
9734 router_with_stub(
9735 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
9736 ),
9737 "/v1/chat/completions",
9738 json!({
9739 "model": "stub-model",
9740 "messages": [{"role": "user", "content": "Use the weather function."}],
9741 "functions": [{
9742 "name": "weather",
9743 "parameters": {
9744 "type": "object",
9745 "properties": {"city": {"type": "string"}},
9746 "required": ["city"]
9747 }
9748 }],
9749 "function_call": "auto"
9750 }),
9751 )
9752 .await;
9753 assert_eq!(response.status(), AxumStatusCode::OK);
9754 let body = response_json(response).await;
9755 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
9756 assert_eq!(body["choices"][0]["message"]["content"], "");
9757 assert_eq!(
9758 body["choices"][0]["message"]["function_call"]["name"],
9759 "weather"
9760 );
9761 assert_eq!(
9762 body["choices"][0]["message"]["function_call"]["arguments"],
9763 "{\"city\":\"Paris\"}"
9764 );
9765 }
9766
9767 #[tokio::test]
9768 async fn route_chat_serializes_legacy_function_call_response() {
9769 let response = post_json(
9770 router_with_stub_api_response(
9771 "",
9772 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
9773 message: ferrum_types::ApiChatMessage {
9774 role: ferrum_types::ApiMessageRole::Assistant,
9775 content: String::new(),
9776 name: None,
9777 tool_calls: vec![],
9778 tool_call_id: None,
9779 function_call: Some(ferrum_types::ApiFunctionCall {
9780 name: "weather".to_string(),
9781 arguments: "{\"city\":\"Paris\"}".to_string(),
9782 }),
9783 },
9784 finish_reason: Some("function_call".to_string()),
9785 }),
9786 ),
9787 "/v1/chat/completions",
9788 json!({
9789 "model": "stub-model",
9790 "messages": [{"role": "user", "content": "Use the weather function."}],
9791 "functions": [{
9792 "name": "weather",
9793 "parameters": {
9794 "type": "object",
9795 "properties": {"city": {"type": "string"}},
9796 "required": ["city"]
9797 }
9798 }],
9799 "function_call": "auto"
9800 }),
9801 )
9802 .await;
9803 assert_eq!(response.status(), AxumStatusCode::OK);
9804 let body = response_json(response).await;
9805 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
9806 assert_eq!(
9807 body["choices"][0]["message"]["function_call"]["name"],
9808 "weather"
9809 );
9810 assert_eq!(
9811 body["choices"][0]["message"]["function_call"]["arguments"],
9812 "{\"city\":\"Paris\"}"
9813 );
9814 }
9815
9816 #[tokio::test]
9817 async fn route_streaming_chat_include_usage_contract() {
9818 let response = post_json(
9819 router_with_stub("ok"),
9820 "/v1/chat/completions",
9821 json!({
9822 "model": "stub-model",
9823 "messages": [{"role": "user", "content": "Say ok"}],
9824 "stream": true,
9825 "stream_options": {"include_usage": true}
9826 }),
9827 )
9828 .await;
9829 assert_eq!(response.status(), AxumStatusCode::OK);
9830 let body = response_text(response).await;
9831 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9832 assert!(
9833 body.contains("\"object\":\"chat.completion.chunk\""),
9834 "missing chat chunk: {body}"
9835 );
9836 assert!(
9837 body.contains("\"usage\":{\"prompt_tokens\""),
9838 "missing final usage chunk: {body}"
9839 );
9840 assert!(
9841 body.contains("\"choices\":[],\"usage\""),
9842 "usage should be emitted as a separate chunk: {body}"
9843 );
9844 assert!(
9845 body.contains("\"prompt_tokens\":5"),
9846 "stream usage should come from engine token usage: {body}"
9847 );
9848 }
9849
9850 #[tokio::test]
9851 async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
9852 let response = post_json(
9853 router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
9854 "/v1/chat/completions",
9855 json!({
9856 "model": "stub-model",
9857 "messages": [{"role": "user", "content": "Say hello"}],
9858 "max_tokens": 2,
9859 "stream": true,
9860 "stream_options": {"include_usage": true}
9861 }),
9862 )
9863 .await;
9864 assert_eq!(response.status(), AxumStatusCode::OK);
9865 let body = response_text(response).await;
9866 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9867 assert!(
9868 body.contains("\"content\":\"he\""),
9869 "missing first chunk: {body}"
9870 );
9871 assert!(
9872 body.contains("\"content\":\"llo\""),
9873 "missing second chunk: {body}"
9874 );
9875 assert!(
9876 body.contains("\"choices\":[],\"usage\""),
9877 "missing separate usage chunk from final engine chunk: {body}"
9878 );
9879 assert!(
9880 body.contains("\"prompt_tokens\":5"),
9881 "stream usage should come from engine final usage: {body}"
9882 );
9883 }
9884
9885 #[tokio::test]
9886 async fn route_streaming_preserves_tokenless_tail_before_terminal() {
9887 for (path, chunks, expected_content, expected_reasoning) in [
9888 ("/v1/chat/completions", ["hello ", "尾"], "hello 尾", ""),
9889 (
9890 "/v1/chat/completions",
9891 ["<think>reason", "</think>"],
9892 "",
9893 "reason",
9894 ),
9895 ("/v1/completions", ["hello ", "尾"], "hello 尾", ""),
9896 ] {
9897 let chat = path == "/v1/chat/completions";
9898 let mut request = json!({"model": "stub-model", "stream": true});
9899 if chat {
9900 request["messages"] = json!([{"role": "user", "content": "hello"}]);
9901 request["stream_options"] = json!({"include_usage": true});
9902 } else {
9903 request["prompt"] = json!("hello");
9904 }
9905 let router = AxumServer::from_llm(Arc::new(StubLlm::with_tokenless_tail(&chunks)))
9906 .build_router();
9907 let response = post_json(router, path, request).await;
9908 assert_eq!(response.status(), AxumStatusCode::OK);
9909 let body = response_text(response).await;
9910 let events = responses_sse_json_events(&body);
9911 let content: String = events
9912 .iter()
9913 .filter_map(|event| {
9914 if chat {
9915 event["choices"][0]["delta"]["content"].as_str()
9916 } else {
9917 event["choices"][0]["text"].as_str()
9918 }
9919 })
9920 .collect();
9921 let reasoning: String = events
9922 .iter()
9923 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
9924 .collect();
9925 assert_eq!(content, expected_content, "body: {body}");
9926 assert_eq!(reasoning, expected_reasoning, "body: {body}");
9927 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9928 assert_eq!(
9929 events
9930 .iter()
9931 .filter(|event| event["choices"][0]["finish_reason"] == "stop")
9932 .count(),
9933 1,
9934 "body: {body}"
9935 );
9936 let usage: Vec<_> = events
9937 .iter()
9938 .filter_map(|event| event["usage"].as_object())
9939 .collect();
9940 assert_eq!(usage.len(), 1, "body: {body}");
9941 assert_eq!(usage[0]["prompt_tokens"], 5);
9942 assert_eq!(usage[0]["completion_tokens"], 2);
9943 }
9944 }
9945
9946 #[tokio::test]
9947 async fn route_rejects_multimodal_content_with_400() {
9948 for content in [
9949 json!([{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}]),
9950 json!([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]),
9951 json!([{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}]),
9952 json!([
9953 {"type": "text", "text": "describe this"},
9954 {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
9955 ]),
9956 ] {
9957 let response = post_json(
9958 router_with_stub("unused"),
9959 "/v1/chat/completions",
9960 json!({
9961 "model": "stub-model",
9962 "messages": [{"role": "user", "content": content}]
9963 }),
9964 )
9965 .await;
9966 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9967 let body = response_json(response).await;
9968 assert_eq!(body["error"]["type"], "invalid_request_error");
9969 let message = body["error"]["message"].as_str().unwrap();
9970 assert!(message.contains("invalid chat completions request"));
9971 assert!(
9972 message.contains("unsupported message content part type"),
9973 "body: {body}"
9974 );
9975 }
9976 }
9977
9978 #[tokio::test]
9979 async fn route_rejects_non_object_stream_options() {
9980 for stream_options in [json!([]), json!("yes"), json!(42), json!(true)] {
9981 let response = post_json(
9982 router_with_stub("unused"),
9983 "/v1/chat/completions",
9984 json!({
9985 "model": "stub-model",
9986 "messages": [{"role": "user", "content": "hello"}],
9987 "stream": true,
9988 "stream_options": stream_options
9989 }),
9990 )
9991 .await;
9992 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9993 let body = response_json(response).await;
9994 assert_eq!(body["error"]["type"], "invalid_request_error");
9995 assert!(
9996 body["error"]["message"]
9997 .as_str()
9998 .unwrap_or_default()
9999 .contains("stream_options must be a JSON object"),
10000 "body: {body}"
10001 );
10002 }
10003 }
10004
10005 #[tokio::test]
10006 async fn route_accepts_text_only_content_array() {
10007 let response = post_json(
10008 router_with_stub("ok"),
10009 "/v1/chat/completions",
10010 json!({
10011 "model": "stub-model",
10012 "messages": [{
10013 "role": "user",
10014 "content": [
10015 {"type": "text", "text": "say"},
10016 {"type": "text", "text": "ok"}
10017 ]
10018 }]
10019 }),
10020 )
10021 .await;
10022 assert_eq!(response.status(), AxumStatusCode::OK);
10023 let body = response_json(response).await;
10024 assert_eq!(body["choices"][0]["message"]["content"], "ok");
10025 }
10026
10027 #[tokio::test]
10028 async fn route_chat_invalid_json_maps_to_openai_error() {
10029 let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
10030 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10031 let body = response_json(response).await;
10032 assert_eq!(body["error"]["type"], "invalid_request_error");
10033 assert_eq!(body["error"]["param"], Value::Null);
10034 assert!(body["error"]["message"]
10035 .as_str()
10036 .unwrap()
10037 .contains("invalid chat completions request"));
10038 }
10039
10040 #[tokio::test]
10041 async fn route_rejects_logit_bias_with_openai_error_param() {
10042 let response = post_json(
10043 router_with_stub("unused"),
10044 "/v1/chat/completions",
10045 json!({
10046 "model": "stub-model",
10047 "messages": [{"role": "user", "content": "hello"}],
10048 "logit_bias": {"1": 42.0}
10049 }),
10050 )
10051 .await;
10052 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10053 let body = response_json(response).await;
10054 assert_eq!(body["error"]["type"], "invalid_request_error");
10055 assert_eq!(body["error"]["param"], "logit_bias");
10056 }
10057
10058 #[tokio::test]
10059 async fn route_tool_request_reaches_engine_structured_boundary() {
10060 for stream in [false, true] {
10061 let (router, engine) = router_with_capturing_llm();
10062 let response = post_json(
10063 router,
10064 "/v1/chat/completions",
10065 json!({
10066 "model": "qwen3",
10067 "messages": [
10068 {"role": "user", "content": "Use the weather tool."},
10069 {
10070 "role": "assistant",
10071 "content": null,
10072 "tool_calls": [{
10073 "id": "call_1",
10074 "type": "function",
10075 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10076 }]
10077 },
10078 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
10079 ],
10080 "tools": [{
10081 "type": "function",
10082 "function": {
10083 "name": "weather",
10084 "description": "Get weather",
10085 "parameters": {
10086 "type": "object",
10087 "properties": {"city": {"type": "string"}},
10088 "required": ["city"]
10089 }
10090 }
10091 }],
10092 "tool_choice": "auto",
10093 "functions": [{
10094 "name": "legacy_weather",
10095 "parameters": {"type": "object", "properties": {}}
10096 }],
10097 "function_call": "auto",
10098 "stream": stream
10099 }),
10100 )
10101 .await;
10102 assert_eq!(response.status(), AxumStatusCode::OK);
10103
10104 if stream {
10105 let body = response_text(response).await;
10106 assert!(body.contains("[DONE]"), "{body}");
10107 assert!(body.contains("captured"), "{body}");
10108 } else {
10109 let body = response_json(response).await;
10110 assert_eq!(body["choices"][0]["message"]["content"], "captured");
10111 assert_eq!(body["choices"][0]["finish_reason"], "stop");
10112 }
10113 let request = engine.last_request();
10114 assert!(request.prompt.contains("\"tools\":[{"));
10115 assert!(request.prompt.contains("\"type\":\"function\""));
10116 assert!(request.prompt.contains("\"name\":\"weather\""));
10117 assert!(request.prompt.contains("<|im_start|>assistant\n{"));
10118 assert!(request.prompt.contains("\"tool_calls\":[{"));
10119 assert!(request.prompt.contains("\"id\":\"call_1\""));
10120 assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
10121 assert_eq!(
10122 request.metadata["openai_tools"][0]["function"]["name"],
10123 "weather"
10124 );
10125 assert_eq!(request.metadata["openai_tool_choice"], "auto");
10126 assert_eq!(
10127 request.metadata["openai_legacy_functions"][0]["name"],
10128 "legacy_weather"
10129 );
10130 assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
10131 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10132 panic!("expected structured chat api_request");
10133 };
10134 assert_eq!(api.messages.len(), 3);
10135 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
10136 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
10137 assert_eq!(api.messages[1].tool_calls[0].id, "call_1");
10138 assert_eq!(api.messages[1].tool_calls[0].function.name, "weather");
10139 assert_eq!(api.messages[2].content, "sunny");
10140 assert_eq!(api.tools[0].function.name, "weather");
10141 assert_eq!(api.legacy_functions[0].name, "legacy_weather");
10142 assert_eq!(
10143 api.messages[1].tool_calls[0].function.arguments,
10144 "{\"city\":\"Paris\"}"
10145 );
10146 }
10147 }
10148
10149 #[tokio::test]
10150 async fn route_replays_reasoning_content_in_qwen36_tool_history_sync() {
10151 let compatibility = capture_qwen36_tool_history_request(
10152 json!({"reasoning_content": "opencode-reasoning-marker"}),
10153 false,
10154 )
10155 .await;
10156 let canonical = capture_qwen36_tool_history_request(
10157 json!({"reasoning": "opencode-reasoning-marker"}),
10158 false,
10159 )
10160 .await;
10161
10162 assert_eq!(compatibility.prompt, canonical.prompt);
10163 assert!(
10164 compatibility.prompt.contains("opencode-reasoning-marker"),
10165 "Qwen3.6 prompt dropped assistant reasoning history: {}",
10166 compatibility.prompt
10167 );
10168 let message = &compatibility.metadata["openai_messages"][1];
10169 assert_eq!(message["reasoning"], "opencode-reasoning-marker");
10170 assert!(message.get("reasoning_content").is_none());
10171 }
10172
10173 #[tokio::test]
10174 async fn route_replays_reasoning_content_in_qwen36_tool_history_stream() {
10175 let request = capture_qwen36_tool_history_request(
10176 json!({"reasoning_content": "opencode-stream-reasoning-marker"}),
10177 true,
10178 )
10179 .await;
10180 assert!(
10181 request.prompt.contains("opencode-stream-reasoning-marker"),
10182 "Qwen3.6 streaming prompt dropped assistant reasoning history: {}",
10183 request.prompt
10184 );
10185 }
10186
10187 #[tokio::test]
10188 async fn route_prefers_canonical_reasoning_in_qwen36_tool_history() {
10189 for stream in [false, true] {
10190 for reasoning in ["canonical-history-marker", ""] {
10191 let request = capture_qwen36_tool_history_request(
10192 json!({
10193 "reasoning": reasoning,
10194 "reasoning_content": "alias-history-marker"
10195 }),
10196 stream,
10197 )
10198 .await;
10199 let canonical =
10200 capture_qwen36_tool_history_request(json!({"reasoning": reasoning}), stream)
10201 .await;
10202 assert_eq!(request.prompt, canonical.prompt);
10203 assert!(!request.prompt.contains("alias-history-marker"));
10204 let message = &request.metadata["openai_messages"][1];
10205 assert_eq!(message["reasoning"], reasoning);
10206 assert!(message.get("reasoning_content").is_none());
10207 }
10208 }
10209 }
10210
10211 #[tokio::test]
10212 async fn route_does_not_force_reasoning_into_templates_that_ignore_it() {
10213 let template = ModelChatTemplate::new(
10214 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}",
10215 "content-only-template",
10216 );
10217 let (router, engine) = router_with_capturing_llm_and_template(template);
10218 let response = post_json(
10219 router,
10220 "/v1/chat/completions",
10221 json!({
10222 "model": "served-alias",
10223 "messages": [
10224 {"role": "user", "content": "hello"},
10225 {
10226 "role": "assistant",
10227 "content": "visible answer",
10228 "reasoning_content": "hidden-reasoning-marker"
10229 },
10230 {"role": "user", "content": "continue"}
10231 ]
10232 }),
10233 )
10234 .await;
10235 assert_eq!(response.status(), AxumStatusCode::OK);
10236
10237 let request = engine.last_request();
10238 assert!(request.prompt.contains("visible answer"));
10239 assert!(!request.prompt.contains("hidden-reasoning-marker"));
10240 }
10241
10242 #[tokio::test]
10243 async fn route_tool_request_prefers_model_chat_template() {
10244 for stream in [false, true] {
10245 let template = ModelChatTemplate::new(
10246 "{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% if message.tool_calls %}{% for tool_call in message.tool_calls %}<tool_call id=\"{{ tool_call.id }}\">{{ tool_call.function.name }}:{{ tool_call.function.arguments }}</tool_call>{% endfor %}{% endif %}{% if message.tool_call_id %}<tool_response id=\"{{ message.tool_call_id }}\">{{ message.content }}</tool_response>{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
10247 "tool-template",
10248 );
10249 let (router, engine) = router_with_capturing_llm_and_template(template);
10250 let response = post_json(
10251 router,
10252 "/v1/chat/completions",
10253 json!({
10254 "model": "served-alias",
10255 "messages": [
10256 {"role": "user", "content": "Use the weather tool."},
10257 {
10258 "role": "assistant",
10259 "content": null,
10260 "tool_calls": [{
10261 "id": "weather_paris",
10262 "type": "function",
10263 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10264 }, {
10265 "id": "weather_rome",
10266 "type": "function",
10267 "function": {"name": "weather", "arguments": "{\"city\":\"Rome\"}"}
10268 }]
10269 },
10270 {"role": "tool", "tool_call_id": "weather_rome", "content": "rainy"},
10273 {"role": "tool", "tool_call_id": "weather_paris", "content": "sunny"}
10274 ],
10275 "tools": [{
10276 "type": "function",
10277 "function": {
10278 "name": "weather",
10279 "description": "Get weather",
10280 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
10281 }
10282 }],
10283 "tool_choice": "auto",
10284 "stream": stream
10285 }),
10286 )
10287 .await;
10288 assert_eq!(response.status(), AxumStatusCode::OK);
10289
10290 if stream {
10291 let body = response_text(response).await;
10292 assert!(body.contains("[DONE]"), "{body}");
10293 assert!(body.contains("captured"), "{body}");
10294 } else {
10295 let body = response_json(response).await;
10296 assert_eq!(body["choices"][0]["message"]["content"], "captured");
10297 assert_eq!(body["choices"][0]["finish_reason"], "stop");
10298 }
10299 let request = engine.last_request();
10300 assert!(request.prompt.contains("<tools>weather</tools>"));
10301 assert!(
10302 request
10303 .prompt
10304 .contains("<tool_call id=\"weather_paris\">weather:"),
10305 "{}",
10306 request.prompt
10307 );
10308 assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
10309 assert!(request.prompt.contains("Paris"), "{}", request.prompt);
10310 assert!(request
10311 .prompt
10312 .contains("<tool_response id=\"weather_paris\">sunny</tool_response>"));
10313 assert!(request
10314 .prompt
10315 .contains("<tool_call id=\"weather_rome\">weather:"));
10316 assert!(request.prompt.contains("Rome"));
10317 assert!(request
10318 .prompt
10319 .contains("<tool_response id=\"weather_rome\">rainy</tool_response>"));
10320 assert!(request.prompt.ends_with("[assistant]"));
10321 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10322 panic!("expected structured continuation request");
10323 };
10324 assert_eq!(api.messages.len(), 4);
10325 assert_eq!(api.messages[1].tool_calls.len(), 2);
10326 for (call, id, city) in [
10327 (&api.messages[1].tool_calls[0], "weather_paris", "Paris"),
10328 (&api.messages[1].tool_calls[1], "weather_rome", "Rome"),
10329 ] {
10330 assert_eq!(call.id, id);
10331 assert_eq!(call.function.name, "weather");
10332 let args: Value = serde_json::from_str(&call.function.arguments).unwrap();
10333 assert_eq!(args, json!({"city": city}));
10334 let prefix = format!("<tool_call id=\"{id}\">weather:");
10335 let rendered_arguments = request
10336 .prompt
10337 .split_once(&prefix)
10338 .unwrap()
10339 .1
10340 .split_once("</tool_call>")
10341 .unwrap()
10342 .0;
10343 let rendered: Value = serde_json::from_str(rendered_arguments).unwrap();
10344 assert_eq!(
10345 rendered,
10346 json!({"city": city}),
10347 "tool arguments lost their call ID binding"
10348 );
10349 }
10350 for (message, id, content) in [
10351 (&api.messages[2], "weather_rome", "rainy"),
10352 (&api.messages[3], "weather_paris", "sunny"),
10353 ] {
10354 assert_eq!(message.role, ferrum_types::ApiMessageRole::Tool);
10355 assert_eq!(message.tool_call_id.as_deref(), Some(id));
10356 assert_eq!(message.content, content);
10357 }
10358 assert!(
10359 !request.prompt.contains("<|assistant|>"),
10360 "model-template tool prompt should not use generic fallback: {}",
10361 request.prompt
10362 );
10363 assert!(
10364 !request.prompt.contains("When a tool is needed"),
10365 "model-template tool prompt should not inject fallback tool instructions: {}",
10366 request.prompt
10367 );
10368 }
10369 }
10370
10371 #[tokio::test]
10372 async fn chat_omitted_output_budget_uses_auto_ceiling() {
10373 let (router, engine) = router_with_capturing_llm();
10374 let response = post_json(
10375 router,
10376 "/v1/chat/completions",
10377 json!({
10378 "model": "stub-model",
10379 "messages": [{"role": "user", "content": "hello"}]
10380 }),
10381 )
10382 .await;
10383 assert_eq!(response.status(), AxumStatusCode::OK);
10384
10385 let request = engine.last_request();
10386 assert_eq!(request.sampling_params.max_tokens, 4096);
10387 assert_eq!(
10388 request.metadata.get(DEFAULT_MAX_TOKENS_METADATA_KEY),
10389 Some(&serde_json::json!(true))
10390 );
10391 }
10392
10393 #[tokio::test]
10394 async fn chat_accepts_stop_string_and_max_completion_tokens() {
10395 let (router, engine) = router_with_capturing_llm();
10396 let response = post_json(
10397 router,
10398 "/v1/chat/completions",
10399 json!({
10400 "model": "stub-model",
10401 "messages": [{"role": "user", "content": "hello"}],
10402 "max_tokens": 99,
10403 "max_completion_tokens": 3,
10404 "stop": "<END>"
10405 }),
10406 )
10407 .await;
10408 assert_eq!(response.status(), AxumStatusCode::OK);
10409
10410 let request = engine.last_request();
10411 let defaults = default_chat_sampling_params();
10412 assert_eq!(request.sampling_params.max_tokens, 3);
10413 assert!(!request
10414 .metadata
10415 .contains_key(DEFAULT_MAX_TOKENS_METADATA_KEY));
10416 assert_eq!(request.sampling_params.temperature, defaults.temperature);
10417 assert_eq!(
10418 request.sampling_params.repetition_penalty,
10419 defaults.repetition_penalty
10420 );
10421 assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
10422 }
10423
10424 #[tokio::test]
10425 async fn chat_maps_vllm_sampling_extensions_without_hidden_defaults() {
10426 let (router, engine) = router_with_capturing_llm();
10427 let response = post_json(
10428 router,
10429 "/v1/chat/completions",
10430 json!({
10431 "model": "stub-model",
10432 "messages": [{"role": "user", "content": "hello"}],
10433 "top_k": 20,
10434 "min_p": 0.05,
10435 "repetition_penalty": 1.25
10436 }),
10437 )
10438 .await;
10439 assert_eq!(response.status(), AxumStatusCode::OK);
10440
10441 let request = engine.last_request();
10442 assert_eq!(request.sampling_params.top_k, Some(20));
10443 assert_eq!(request.sampling_params.min_p, Some(0.05));
10444 assert_eq!(request.sampling_params.repetition_penalty, 1.25);
10445 }
10446
10447 #[tokio::test]
10448 async fn chat_normalizes_disabled_sampling_extensions_and_rejects_invalid_ranges() {
10449 let (router, engine) = router_with_capturing_llm();
10450 let response = post_json(
10451 router,
10452 "/v1/chat/completions",
10453 json!({
10454 "model": "stub-model",
10455 "messages": [{"role": "user", "content": "hello"}],
10456 "top_k": -1,
10457 "min_p": 0.0,
10458 "repetition_penalty": 1.0
10459 }),
10460 )
10461 .await;
10462 assert_eq!(response.status(), AxumStatusCode::OK);
10463 let request = engine.last_request();
10464 assert_eq!(request.sampling_params.top_k, None);
10465 assert_eq!(request.sampling_params.min_p, None);
10466 assert_eq!(request.sampling_params.repetition_penalty, 1.0);
10467
10468 for (field, value) in [
10469 ("top_k", json!(-2)),
10470 ("min_p", json!(1.01)),
10471 ("repetition_penalty", json!(0.0)),
10472 ("presence_penalty", json!(2.01)),
10473 ("frequency_penalty", json!(-2.01)),
10474 ] {
10475 let (router, _) = router_with_capturing_llm();
10476 let response = post_json(
10477 router,
10478 "/v1/chat/completions",
10479 json!({
10480 "model": "stub-model",
10481 "messages": [{"role": "user", "content": "hello"}],
10482 (field): value
10483 }),
10484 )
10485 .await;
10486 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST, "{field}");
10487 let body = response_json(response).await;
10488 assert_eq!(body["error"]["param"], field);
10489 }
10490 }
10491
10492 #[tokio::test]
10493 async fn chat_request_forbids_initial_think_close_token() {
10494 let engine = Arc::new(CapturingLlm::new());
10495 let router = AxumServer::from_llm(engine.clone()).build_router();
10496 let response = post_json(
10497 router,
10498 "/v1/chat/completions",
10499 json!({
10500 "model": "qwen3",
10501 "messages": [{"role": "user", "content": "hello"}]
10502 }),
10503 )
10504 .await;
10505 assert_eq!(response.status(), AxumStatusCode::OK);
10506
10507 let request = engine.last_request();
10508 assert_eq!(
10509 request
10510 .metadata
10511 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10512 Some(&serde_json::json!([THINK_END_TAG]))
10513 );
10514 }
10515
10516 #[tokio::test]
10517 async fn omitted_enable_thinking_preserves_model_template_default() {
10518 let template = ModelChatTemplate::new(
10519 "{% 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 %}",
10520 "test-template",
10521 );
10522 let (router, engine) = router_with_capturing_llm_and_template(template);
10523 let response = post_json(
10524 router,
10525 "/v1/chat/completions",
10526 json!({
10527 "model": "served-alias",
10528 "messages": [{"role": "user", "content": "hello"}]
10529 }),
10530 )
10531 .await;
10532 assert_eq!(response.status(), AxumStatusCode::OK);
10533
10534 let request = engine.last_request();
10535 assert!(request.prompt.ends_with("<|im_start|>assistant\n<think>\n"));
10536 assert!(!request
10537 .metadata
10538 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10539 }
10540
10541 #[tokio::test]
10542 async fn server_thinking_default_applies_but_request_override_wins() {
10543 let template = ModelChatTemplate::new(
10544 "{% 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 %}",
10545 "test-template",
10546 );
10547 let (router, engine) =
10548 router_with_capturing_llm_and_template_default(template, Some(false));
10549
10550 let response = post_json(
10551 router.clone(),
10552 "/v1/chat/completions",
10553 json!({
10554 "model": "served-alias",
10555 "messages": [{"role": "user", "content": "hello"}]
10556 }),
10557 )
10558 .await;
10559 assert_eq!(response.status(), AxumStatusCode::OK);
10560 let request = engine.last_request();
10561 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
10562 assert_eq!(
10563 request
10564 .metadata
10565 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10566 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
10567 );
10568
10569 let response = post_json(
10570 router,
10571 "/v1/chat/completions",
10572 json!({
10573 "model": "served-alias",
10574 "messages": [{"role": "user", "content": "hello"}],
10575 "chat_template_kwargs": {"enable_thinking": true}
10576 }),
10577 )
10578 .await;
10579 assert_eq!(response.status(), AxumStatusCode::OK);
10580 let request = engine.last_request();
10581 assert_eq!(request.prompt, "<assistant><think>\n");
10582 assert!(!request
10583 .metadata
10584 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10585 }
10586
10587 #[tokio::test]
10588 async fn chat_template_enable_thinking_true_overrides_default() {
10589 let template = ModelChatTemplate::new(
10590 "{% 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 %}",
10591 "test-template",
10592 );
10593 let (router, engine) = router_with_capturing_llm_and_template(template);
10594 let response = post_json(
10595 router,
10596 "/v1/chat/completions",
10597 json!({
10598 "model": "served-alias",
10599 "messages": [{"role": "user", "content": "hello"}],
10600 "chat_template_kwargs": {"enable_thinking": true}
10601 }),
10602 )
10603 .await;
10604 assert_eq!(response.status(), AxumStatusCode::OK);
10605
10606 let request = engine.last_request();
10607 assert_eq!(request.prompt, "<assistant><think>\n");
10608 assert!(!request
10609 .metadata
10610 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10611 }
10612
10613 #[tokio::test]
10614 async fn chat_template_enable_thinking_false_is_a_hard_override() {
10615 let template = ModelChatTemplate::new(
10616 "{% 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 %}",
10617 "test-template",
10618 );
10619 let (router, engine) = router_with_capturing_llm_and_template(template);
10620 let response = post_json(
10621 router,
10622 "/v1/chat/completions",
10623 json!({
10624 "model": "served-alias",
10625 "messages": [{"role": "user", "content": "hello"}],
10626 "chat_template_kwargs": {"enable_thinking": false}
10627 }),
10628 )
10629 .await;
10630 assert_eq!(response.status(), AxumStatusCode::OK);
10631
10632 let request = engine.last_request();
10633 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
10634 assert_eq!(
10635 request
10636 .metadata
10637 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10638 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
10639 );
10640 }
10641
10642 #[tokio::test]
10643 async fn chat_template_reasoning_effort_is_typed_and_rendered() {
10644 let template = ModelChatTemplate::new(
10645 "{% if reasoning_effort is defined %}Reasoning: {{ reasoning_effort }}{% else %}Reasoning: model-default{% endif %}",
10646 "test-template",
10647 );
10648 let (router, engine) = router_with_capturing_llm_and_template(template);
10649 let response = post_json(
10650 router.clone(),
10651 "/v1/chat/completions",
10652 json!({
10653 "model": "served-alias",
10654 "messages": [{"role": "user", "content": "hello"}],
10655 "chat_template_kwargs": {"reasoning_effort": "low"}
10656 }),
10657 )
10658 .await;
10659 assert_eq!(response.status(), AxumStatusCode::OK);
10660 assert_eq!(engine.last_request().prompt, "Reasoning: low");
10661
10662 let response = post_json(
10663 router.clone(),
10664 "/v1/chat/completions",
10665 json!({
10666 "model": "served-alias",
10667 "messages": [{"role": "user", "content": "hello"}],
10668 "chat_template_kwargs": {"reasoning_effort": "xhigh"}
10669 }),
10670 )
10671 .await;
10672 assert_eq!(response.status(), AxumStatusCode::OK);
10673 assert_eq!(engine.last_request().prompt, "Reasoning: xhigh");
10674
10675 for invalid in [json!("extreme"), json!(1)] {
10676 let response = post_json(
10677 router.clone(),
10678 "/v1/chat/completions",
10679 json!({
10680 "model": "served-alias",
10681 "messages": [{"role": "user", "content": "hello"}],
10682 "chat_template_kwargs": {"reasoning_effort": invalid}
10683 }),
10684 )
10685 .await;
10686 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10687 let body = response_json(response).await;
10688 assert_eq!(body["error"]["type"], "invalid_request_error");
10689 assert!(body["error"]["message"]
10690 .as_str()
10691 .unwrap_or_default()
10692 .contains("reasoning_effort"));
10693 }
10694 }
10695
10696 #[tokio::test]
10697 async fn chat_template_enable_thinking_rejects_non_bool() {
10698 let template = ModelChatTemplate::new(
10699 "{% if add_generation_prompt %}<assistant>{% endif %}",
10700 "test-template",
10701 );
10702 let (router, _) = router_with_capturing_llm_and_template(template);
10703 let response = post_json(
10704 router,
10705 "/v1/chat/completions",
10706 json!({
10707 "model": "served-alias",
10708 "messages": [{"role": "user", "content": "hello"}],
10709 "chat_template_kwargs": {"enable_thinking": "false"}
10710 }),
10711 )
10712 .await;
10713 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10714 let body = response_json(response).await;
10715 assert_eq!(body["error"]["type"], "invalid_request_error");
10716 assert!(body["error"]["message"]
10717 .as_str()
10718 .unwrap_or_default()
10719 .contains("chat_template_kwargs.enable_thinking must be a boolean"));
10720 }
10721
10722 #[tokio::test]
10723 async fn stop_string_strips_chat_and_completion_suffixes() {
10724 let chat = post_json(
10725 router_with_stub("hello<END>"),
10726 "/v1/chat/completions",
10727 json!({
10728 "model": "stub-model",
10729 "messages": [{"role": "user", "content": "hello"}],
10730 "stop": "<END>"
10731 }),
10732 )
10733 .await;
10734 assert_eq!(chat.status(), AxumStatusCode::OK);
10735 let chat_body = response_json(chat).await;
10736 assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
10737
10738 let completion = post_json(
10739 router_with_stub("done<END>"),
10740 "/v1/completions",
10741 json!({
10742 "model": "stub-model",
10743 "prompt": "complete",
10744 "stop": "<END>"
10745 }),
10746 )
10747 .await;
10748 assert_eq!(completion.status(), AxumStatusCode::OK);
10749 let completion_body = response_json(completion).await;
10750 assert_eq!(completion_body["choices"][0]["text"], "done");
10751 }
10752
10753 #[test]
10754 fn started_in_think_parse_streams_reasoning_before_end_tag() {
10755 let parsed = parse_reasoning_response_started_in_think("Okay, the user wants");
10759 assert_eq!(parsed.reasoning.as_deref(), Some("Okay, the user wants"));
10760 assert_eq!(parsed.content, "");
10761
10762 let parsed = parse_reasoning_response_started_in_think("thinking...</think>\nanswer");
10763 assert_eq!(parsed.reasoning.as_deref(), Some("thinking..."));
10764 assert_eq!(parsed.content, "answer");
10765
10766 let parsed = parse_reasoning_response_started_in_think("<think>\nx\n</think>\n\nanswer");
10768 assert_eq!(parsed.reasoning.as_deref(), Some("\nx\n"));
10769 assert_eq!(parsed.content, "answer");
10770 }
10771
10772 #[tokio::test]
10773 async fn chat_response_splits_reasoning_from_content() {
10774 let response = post_json(
10775 router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
10776 "/v1/chat/completions",
10777 json!({
10778 "model": "stub-model",
10779 "messages": [{"role": "user", "content": "hello"}]
10780 }),
10781 )
10782 .await;
10783 assert_eq!(response.status(), AxumStatusCode::OK);
10784
10785 let body = response_json(response).await;
10786 let message = &body["choices"][0]["message"];
10787 assert_eq!(message["content"], "final answer");
10788 assert_eq!(message["reasoning"], "\nreasoning\n");
10789 assert!(message.get("reasoning_content").is_none());
10790 }
10791
10792 #[tokio::test]
10793 async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
10794 let response = post_json(
10795 router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
10796 "/v1/chat/completions",
10797 json!({
10798 "model": "stub-model",
10799 "messages": [{"role": "user", "content": "think then answer"}],
10800 "stream": true
10801 }),
10802 )
10803 .await;
10804 assert_eq!(response.status(), AxumStatusCode::OK);
10805 let body = response_text(response).await;
10806 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10807 assert!(
10808 body.contains(r#""reasoning":"\nreason"#),
10809 "stream should emit reasoning delta after full think prefix: {body}"
10810 );
10811 assert!(!body.contains("\"reasoning_content\":"));
10812 assert!(
10813 body.contains(r#""content":"final""#),
10814 "stream should emit visible content after think close: {body}"
10815 );
10816 assert!(
10817 !body.contains(r#""content":"<"#),
10818 "partial think prefix must not leak as content: {body}"
10819 );
10820 }
10821
10822 #[tokio::test]
10823 async fn route_rejects_unsupported_tool_and_function_selection() {
10824 for (extra, param) in [
10825 (
10826 json!({
10827 "tools": [{
10828 "type": "function",
10829 "function": {"name": "weather", "parameters": {"type": "object"}}
10830 }],
10831 "tool_choice": {
10832 "type": "function",
10833 "function": {"name": "calendar"}
10834 }
10835 }),
10836 "tool_choice",
10837 ),
10838 (
10839 json!({
10840 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
10841 "function_call": {"name": "calendar"}
10842 }),
10843 "function_call",
10844 ),
10845 ] {
10846 let mut body = json!({
10847 "model": "stub-model",
10848 "messages": [{"role": "user", "content": "hello"}]
10849 });
10850 body.as_object_mut()
10851 .expect("object")
10852 .extend(extra.as_object().expect("extra object").clone());
10853 let response =
10854 post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
10855 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10856 let body = response_json(response).await;
10857 assert_eq!(body["error"]["type"], "invalid_request_error");
10858 assert_eq!(body["error"]["param"], param);
10859 }
10860 }
10861
10862 #[tokio::test]
10863 async fn route_rejects_non_function_tools_with_openai_error_param() {
10864 let response = post_json(
10865 router_with_stub("unused"),
10866 "/v1/chat/completions",
10867 json!({
10868 "model": "stub-model",
10869 "messages": [{"role": "user", "content": "hello"}],
10870 "tools": [{
10871 "type": "retrieval",
10872 "function": {"name": "search", "parameters": {"type": "object"}}
10873 }]
10874 }),
10875 )
10876 .await;
10877 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10878 let body = response_json(response).await;
10879 assert_eq!(body["error"]["type"], "invalid_request_error");
10880 assert_eq!(body["error"]["param"], "tools");
10881 }
10882
10883 #[tokio::test]
10884 async fn route_rejects_tool_choice_required_without_tools() {
10885 let response = post_json(
10886 router_with_stub("unused"),
10887 "/v1/chat/completions",
10888 json!({
10889 "model": "stub-model",
10890 "messages": [{"role": "user", "content": "hello"}],
10891 "tool_choice": "required"
10892 }),
10893 )
10894 .await;
10895 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10896 let body = response_json(response).await;
10897 assert_eq!(body["error"]["type"], "invalid_request_error");
10898 assert_eq!(body["error"]["param"], "tool_choice");
10899 }
10900
10901 #[tokio::test]
10902 async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
10903 let response = post_json(
10904 router_with_stub("unused"),
10905 "/v1/chat/completions",
10906 json!({
10907 "model": "stub-model",
10908 "messages": [{"role": "user", "content": "hello"}],
10909 "response_format": {"type": "xml"}
10910 }),
10911 )
10912 .await;
10913 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10914 let body = response_json(response).await;
10915 assert_eq!(body["error"]["type"], "invalid_request_error");
10916 assert_eq!(body["error"]["param"], "response_format.type");
10917 }
10918
10919 #[tokio::test]
10920 async fn route_chat_engine_unavailable_maps_to_503() {
10921 let response = post_json(
10922 router_without_llm(),
10923 "/v1/chat/completions",
10924 json!({
10925 "model": "stub-model",
10926 "messages": [{"role": "user", "content": "hello"}]
10927 }),
10928 )
10929 .await;
10930 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
10931 let body = response_json(response).await;
10932 assert_eq!(body["error"]["type"], "service_unavailable_error");
10933 assert_eq!(body["error"]["param"], Value::Null);
10934 }
10935
10936 #[tokio::test]
10937 async fn route_chat_generation_failure_maps_to_500() {
10938 let response = post_json(
10939 router_with_failing_llm(),
10940 "/v1/chat/completions",
10941 json!({
10942 "model": "failing-model",
10943 "messages": [{"role": "user", "content": "hello"}]
10944 }),
10945 )
10946 .await;
10947 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
10948 let body = response_json(response).await;
10949 assert_eq!(body["error"]["type"], "internal_server_error");
10950 assert!(body["error"]["message"]
10951 .as_str()
10952 .unwrap()
10953 .contains("stub generation failed"));
10954 }
10955
10956 #[tokio::test]
10957 async fn route_chat_generation_failure_writes_replay_diagnostics() {
10958 let root = unique_request_dump_dir("chat-sync-failure");
10959 let profile = unique_profile_jsonl("chat-sync-failure");
10960 let response = post_json(
10961 router_with_failing_llm_request_dump_and_profile(root.clone(), profile.clone()),
10962 "/v1/chat/completions",
10963 json!({
10964 "model": "failing-model",
10965 "messages": [{"role": "user", "content": "hello"}]
10966 }),
10967 )
10968 .await;
10969 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
10970 assert_chat_failure_replay_bundle(
10971 &root,
10972 "chat_completions_sync",
10973 "internal",
10974 "stub generation failed",
10975 );
10976 let event = read_profile_events(&profile)
10977 .into_iter()
10978 .find(|event| event["phase"] == "chat_completions_sync")
10979 .expect("sync failure profile event");
10980 assert_eq!(event["event_kind"], "timed_span");
10981 assert_eq!(event["status"], "failure");
10982 assert!(event["duration_us"].as_u64().is_some());
10983 assert_eq!(event["attributes"]["terminal_failure_event"], true);
10984 assert_eq!(event["error"]["kind"], "internal");
10985 let _ = fs::remove_dir_all(root);
10986 let _ = fs::remove_file(profile);
10987 }
10988
10989 #[tokio::test]
10990 async fn route_chat_resource_failure_writes_resource_replay_diagnostics() {
10991 let root = unique_request_dump_dir("chat-resource-failure");
10992 let response = post_json(
10993 router_with_resource_exhausted_llm_and_request_dump_dir(root.clone()),
10994 "/v1/chat/completions",
10995 json!({
10996 "model": "failing-model",
10997 "messages": [{"role": "user", "content": "hello"}]
10998 }),
10999 )
11000 .await;
11001 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11002 let bundle = only_replay_bundle(&root);
11003 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11004 assert_eq!(bad_scan["failure_kind"], "oom_admission");
11005 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
11006 assert_eq!(diagnostics["failure_kind"], "oom_admission");
11007 assert_eq!(
11008 diagnostics["first_failure_event"]["error_kind"],
11009 "resource_exhausted"
11010 );
11011 assert_eq!(
11012 diagnostics["capacity"]["resource_kind"],
11013 "admission_capacity"
11014 );
11015 assert!(diagnostics["capacity"]["reason"]
11016 .as_str()
11017 .expect("capacity reason")
11018 .contains("admission capacity exhausted"));
11019 assert_eq!(
11020 diagnostics["nearest_resource_event"]["resource_kind"],
11021 "admission_capacity"
11022 );
11023 assert!(diagnostics["nearest_memory_snapshot"]["current_bytes"].is_number());
11024 assert!(diagnostics["nearest_memory_snapshot"]["high_water_bytes"].is_number());
11025 let _ = fs::remove_dir_all(root);
11026 }
11027
11028 #[tokio::test]
11029 async fn route_chat_sync_success_updates_replay_output_tokens() {
11030 let root = unique_request_dump_dir("chat-sync-success-output");
11031 let response = post_json(
11032 router_with_stub_and_request_dump_dir("OK", root.clone()),
11033 "/v1/chat/completions",
11034 json!({
11035 "model": "stub-model",
11036 "messages": [{"role": "user", "content": "hello"}]
11037 }),
11038 )
11039 .await;
11040 assert_eq!(response.status(), AxumStatusCode::OK);
11041 let body = response_json(response).await;
11042 assert_eq!(body["choices"][0]["message"]["content"], "OK");
11043 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11044 let _ = fs::remove_dir_all(root);
11045 }
11046
11047 #[tokio::test]
11048 async fn route_chat_sync_success_writes_product_profile_event() {
11049 let root = unique_request_dump_dir("chat-sync-profile");
11050 let profile = unique_profile_jsonl("chat-sync-profile");
11051 let response = post_json(
11052 router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11053 "/v1/chat/completions",
11054 json!({
11055 "model": "stub-model",
11056 "messages": [{"role": "user", "content": "hello"}]
11057 }),
11058 )
11059 .await;
11060 assert_eq!(response.status(), AxumStatusCode::OK);
11061 let _ = response_json(response).await;
11062
11063 let events = read_profile_events(&profile);
11064 assert_eq!(events.len(), 2, "events: {events:#?}");
11065 let event = events
11066 .iter()
11067 .find(|event| event["phase"] == "chat_completions_sync_complete")
11068 .expect("sync completion profile event");
11069 assert_eq!(
11070 event["schema_version"],
11071 OBSERVABILITY_PROFILE_SCHEMA_VERSION
11072 );
11073 assert_eq!(event["entrypoint"], "serve");
11074 assert_eq!(event["event_kind"], "timed_span");
11075 assert_eq!(event["status"], "ok");
11076 assert_eq!(event["phase"], "chat_completions_sync_complete");
11077 assert_eq!(event["attributes"]["actual_model_smoke"], true);
11078 assert_eq!(event["attributes"]["profile_detail"], "latency");
11079 assert_eq!(event["attributes"]["diagnostic_only"], false);
11080 assert_eq!(event["attributes"]["stream"], false);
11081 assert_eq!(event["attributes"]["output_token_count"], 2);
11082 assert_eq!(event["attributes"]["prompt_token_count"], 7);
11083 assert_eq!(event["attributes"]["completion_token_count"], 2);
11084 assert_eq!(event["attributes"]["total_token_count"], 9);
11085 assert_eq!(event["attributes"]["token_count_source"], "usage");
11086 assert_eq!(event["attributes"]["finish_reason"], "stop");
11087 assert_eq!(
11088 event["attributes"]["engine_token_clock_source"],
11089 "rust_std_instant"
11090 );
11091 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11092 assert_eq!(event["attributes"]["itl_interval_count"], 1);
11093 assert_eq!(event["attributes"]["ttft_us"], 1_000);
11094 assert_eq!(event["attributes"]["itl_us_avg"], 1_000);
11095 assert!(event["attributes"]["http_first_sse_enqueue_us"].is_null());
11096 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11097 assert!(
11098 event["attributes"]["e2e_duration_us"]
11099 .as_u64()
11100 .unwrap_or_default()
11101 > 0
11102 );
11103 assert_eq!(
11104 event["replay"]["bundle_dir"].as_str(),
11105 Some(root.to_string_lossy().as_ref())
11106 );
11107 assert!(event["replay"]["command"]
11108 .as_str()
11109 .unwrap_or_default()
11110 .contains("replay_body.json"));
11111 let memory_event = events
11112 .iter()
11113 .find(|event| event["phase"] == "actual_serve_first_request_done")
11114 .expect("first request memory profile event");
11115 assert_eq!(memory_event["event_kind"], "memory");
11116 assert_eq!(
11117 memory_event["attributes"]["memory_stage"],
11118 "first_request_done"
11119 );
11120 assert_eq!(
11121 memory_event["attributes"]["memory_measurement"],
11122 "process_rss"
11123 );
11124 assert!(memory_event["memory"]["current_bytes"]
11125 .as_u64()
11126 .is_some_and(|bytes| bytes > 0));
11127 let _ = fs::remove_dir_all(root);
11128 let _ = fs::remove_file(profile);
11129 }
11130
11131 #[tokio::test]
11132 async fn route_chat_profile_events_preserve_benchmark_correlation() {
11133 let root = unique_request_dump_dir("chat-benchmark-correlation");
11134 let profile = unique_profile_jsonl("chat-benchmark-correlation");
11135 let correlation = BenchmarkRequestCorrelation::new(
11136 "bench-123".to_string(),
11137 "cell-1-closed-c8".to_string(),
11138 2,
11139 ferrum_bench_core::BenchmarkPhase::Measured,
11140 17,
11141 )
11142 .unwrap();
11143 let response = post_json_with_benchmark_correlation(
11144 router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11145 "/v1/chat/completions",
11146 json!({
11147 "model": "stub-model",
11148 "messages": [{"role": "user", "content": "hello"}]
11149 }),
11150 &correlation,
11151 )
11152 .await;
11153 assert_eq!(response.status(), AxumStatusCode::OK);
11154 let _ = response_json(response).await;
11155
11156 let events = read_profile_events(&profile);
11157 assert_eq!(events.len(), 2, "events: {events:#?}");
11158 for event in events {
11159 assert_eq!(event["attributes"]["benchmark_run_id"], "bench-123");
11160 assert_eq!(event["attributes"]["cell_id"], "cell-1-closed-c8");
11161 assert_eq!(event["attributes"]["repeat_index"], 2);
11162 assert_eq!(event["attributes"]["phase"], "measured");
11163 assert_eq!(event["attributes"]["request_index"], 17);
11164 }
11165 let _ = fs::remove_dir_all(root);
11166 let _ = fs::remove_file(profile);
11167 }
11168
11169 #[tokio::test]
11170 async fn route_chat_rejects_partial_benchmark_correlation_headers() {
11171 let response = router_with_stub("OK")
11172 .oneshot(
11173 Request::builder()
11174 .method("POST")
11175 .uri("/v1/chat/completions")
11176 .header(header::CONTENT_TYPE, "application/json")
11177 .header(BENCHMARK_RUN_ID_HEADER, "bench-123")
11178 .body(Body::from(
11179 json!({
11180 "model": "stub-model",
11181 "messages": [{"role": "user", "content": "hello"}]
11182 })
11183 .to_string(),
11184 ))
11185 .expect("request"),
11186 )
11187 .await
11188 .expect("route response");
11189 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11190 }
11191
11192 #[tokio::test]
11193 async fn route_chat_sync_profile_jsonl_is_parseable_under_concurrent_requests() {
11194 let root = unique_request_dump_dir("chat-sync-profile-concurrent");
11195 let profile = unique_profile_jsonl("chat-sync-profile-concurrent");
11196 let app = router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone());
11197
11198 let mut handles = Vec::new();
11199 for request_index in 0..8 {
11200 let app = app.clone();
11201 handles.push(tokio::spawn(async move {
11202 let response = post_json(
11203 app,
11204 "/v1/chat/completions",
11205 json!({
11206 "model": "stub-model",
11207 "messages": [{"role": "user", "content": format!("hello {request_index}")}]
11208 }),
11209 )
11210 .await;
11211 assert_eq!(response.status(), AxumStatusCode::OK);
11212 let body = response_json(response).await;
11213 assert_eq!(body["choices"][0]["message"]["content"], "OK");
11214 }));
11215 }
11216
11217 for handle in handles {
11218 handle.await.expect("concurrent request task");
11219 }
11220
11221 let raw = fs::read_to_string(&profile).expect("profile jsonl");
11222 let mut completion_events = 0usize;
11223 for (line_index, line) in raw
11224 .lines()
11225 .filter(|line| !line.trim().is_empty())
11226 .enumerate()
11227 {
11228 let event: Value = serde_json::from_str(line).unwrap_or_else(|err| {
11229 panic!(
11230 "profile line {} invalid JSON: {err}: {line}",
11231 line_index + 1
11232 )
11233 });
11234 if event["phase"] == "chat_completions_sync_complete" {
11235 completion_events += 1;
11236 }
11237 }
11238 assert_eq!(completion_events, 8);
11239 let _ = fs::remove_dir_all(root);
11240 let _ = fs::remove_file(profile);
11241 }
11242
11243 #[tokio::test]
11244 async fn route_chat_stream_success_updates_replay_output_tokens() {
11245 let root = unique_request_dump_dir("chat-stream-success-output");
11246 let response = post_json(
11247 router_with_stub_stream_chunks_and_request_dump_dir(&["O", "K"], root.clone()),
11248 "/v1/chat/completions",
11249 json!({
11250 "model": "stub-model",
11251 "messages": [{"role": "user", "content": "hello"}],
11252 "stream": true
11253 }),
11254 )
11255 .await;
11256 assert_eq!(response.status(), AxumStatusCode::OK);
11257 let body = response_text(response).await;
11258 assert!(body.contains("data: [DONE]"), "body: {body}");
11259 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11260 let _ = fs::remove_dir_all(root);
11261 }
11262
11263 #[tokio::test]
11264 async fn route_chat_stream_success_writes_product_profile_event() {
11265 let root = unique_request_dump_dir("chat-stream-profile");
11266 let profile = unique_profile_jsonl("chat-stream-profile");
11267 let response = post_json(
11268 router_with_stub_stream_request_dump_and_profile(
11269 &["O", "K"],
11270 root.clone(),
11271 profile.clone(),
11272 ),
11273 "/v1/chat/completions",
11274 json!({
11275 "model": "stub-model",
11276 "messages": [{"role": "user", "content": "hello"}],
11277 "stream": true
11278 }),
11279 )
11280 .await;
11281 assert_eq!(response.status(), AxumStatusCode::OK);
11282 let body = response_text(response).await;
11283 assert!(body.contains("data: [DONE]"), "body: {body}");
11284
11285 let events = read_profile_events(&profile);
11286 assert_eq!(events.len(), 2, "events: {events:#?}");
11287 let event = events
11288 .iter()
11289 .find(|event| event["phase"] == "chat_completions_stream_complete")
11290 .expect("stream completion profile event");
11291 assert_eq!(
11292 event["schema_version"],
11293 OBSERVABILITY_PROFILE_SCHEMA_VERSION
11294 );
11295 assert_eq!(event["entrypoint"], "serve");
11296 assert_eq!(event["event_kind"], "timed_span");
11297 assert_eq!(event["status"], "ok");
11298 assert_eq!(event["phase"], "chat_completions_stream_complete");
11299 assert_eq!(event["attributes"]["actual_model_smoke"], true);
11300 assert_eq!(event["attributes"]["profile_detail"], "latency");
11301 assert_eq!(event["attributes"]["diagnostic_only"], false);
11302 assert_eq!(event["attributes"]["stream"], true);
11303 assert_eq!(event["attributes"]["output_token_count"], 2);
11304 assert_eq!(event["attributes"]["prompt_token_count"], 5);
11305 assert_eq!(event["attributes"]["completion_token_count"], 2);
11306 assert_eq!(event["attributes"]["total_token_count"], 7);
11307 assert_eq!(event["attributes"]["token_count_source"], "usage");
11308 assert_eq!(event["attributes"]["finish_reason"], "stop");
11309 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11310 assert!(
11311 event["attributes"]["e2e_duration_us"]
11312 .as_u64()
11313 .unwrap_or_default()
11314 > 0
11315 );
11316 assert!(event["attributes"]["ttft_us"].as_u64().is_some());
11317 assert!(event["attributes"]["itl_us_avg"].as_u64().is_some());
11318 assert_eq!(
11319 event["attributes"]["engine_token_commit_nanos_since_request_start"],
11320 json!([1_000_000, 2_000_000])
11321 );
11322 assert_eq!(event["attributes"]["itl_interval_count"], 1);
11323 assert_eq!(event["attributes"]["itl_source"], "engine_token_commit");
11324 assert!(event["attributes"]["engine_stream_first_chunk_received_us"]
11325 .as_u64()
11326 .is_some());
11327 assert!(event["attributes"]["http_first_sse_enqueue_us"]
11328 .as_u64()
11329 .is_some());
11330 assert!(event["attributes"]["http_stream_flush_unavailable_reason"]
11331 .as_str()
11332 .is_some());
11333 assert_eq!(
11334 event["replay"]["bundle_dir"].as_str(),
11335 Some(root.to_string_lossy().as_ref())
11336 );
11337 let memory_event = events
11338 .iter()
11339 .find(|event| event["phase"] == "actual_serve_first_request_done")
11340 .expect("first request memory profile event");
11341 assert_eq!(memory_event["event_kind"], "memory");
11342 assert_eq!(
11343 memory_event["attributes"]["memory_stage"],
11344 "first_request_done"
11345 );
11346 assert_eq!(
11347 memory_event["attributes"]["memory_measurement"],
11348 "process_rss"
11349 );
11350 assert!(memory_event["memory"]["current_bytes"]
11351 .as_u64()
11352 .is_some_and(|bytes| bytes > 0));
11353 let _ = fs::remove_dir_all(root);
11354 let _ = fs::remove_file(profile);
11355 }
11356
11357 #[tokio::test]
11358 async fn route_chat_stream_profile_retains_non_visible_terminal_token() {
11359 let root = unique_request_dump_dir("chat-stream-profile-terminal-token");
11360 let profile = unique_profile_jsonl("chat-stream-profile-terminal-token");
11361 let llm = StubLlm {
11362 stream_usage: Some(TokenUsage::new(5, 2)),
11363 ..StubLlm::with_stream_chunks(&["Paris"])
11364 };
11365 let app = AxumServer::from_state(
11366 AppState::default()
11367 .with_llm(Arc::new(llm))
11368 .with_request_dump_dir(Some(root.clone()))
11369 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
11370 .with_profile_jsonl(Some(profile.clone())),
11371 )
11372 .build_router();
11373
11374 let response = post_json(
11375 app,
11376 "/v1/chat/completions",
11377 json!({
11378 "model": "stub-model",
11379 "messages": [{"role": "user", "content": "hello"}],
11380 "stream": true,
11381 "stream_options": {"include_usage": true}
11382 }),
11383 )
11384 .await;
11385 assert_eq!(response.status(), AxumStatusCode::OK);
11386 let body = response_text(response).await;
11387 assert!(body.contains("\"completion_tokens\":2"), "body: {body}");
11388 assert!(body.contains("data: [DONE]"), "body: {body}");
11389
11390 let events = read_profile_events(&profile);
11391 let event = events
11392 .iter()
11393 .find(|event| event["phase"] == "chat_completions_stream_complete")
11394 .expect("stream completion profile event");
11395 assert_eq!(event["attributes"]["output_token_count"], 2);
11396 assert_eq!(event["attributes"]["completion_token_count"], 2);
11397 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11398 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "Paris");
11399
11400 let _ = fs::remove_dir_all(root);
11401 let _ = fs::remove_file(profile);
11402 }
11403
11404 #[tokio::test]
11405 async fn route_chat_sync_bad_output_updates_replay_classifier() {
11406 let root = unique_request_dump_dir("chat-sync-bad-output");
11407 let response = post_json(
11408 router_with_stub_and_request_dump_dir("<unk>", root.clone()),
11409 "/v1/chat/completions",
11410 json!({
11411 "model": "stub-model",
11412 "messages": [{"role": "user", "content": "hello"}]
11413 }),
11414 )
11415 .await;
11416 assert_eq!(response.status(), AxumStatusCode::OK);
11417 let _ = response_json(response).await;
11418 let bundle = only_replay_bundle(&root);
11419 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11420 assert_eq!(bad_scan["bad_output"], true);
11421 assert_eq!(bad_scan["reasons"], json!(["reserved_token"]));
11422 assert_eq!(bad_scan["first_bad_text_span"]["reason"], "reserved_token");
11423 let _ = fs::remove_dir_all(root);
11424 }
11425
11426 #[tokio::test]
11427 async fn route_chat_stream_generation_failure_emits_openai_error_event() {
11428 let response = post_json(
11429 router_with_failing_llm(),
11430 "/v1/chat/completions",
11431 json!({
11432 "model": "failing-model",
11433 "messages": [{"role": "user", "content": "hello"}],
11434 "stream": true
11435 }),
11436 )
11437 .await;
11438 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11439 let body = response_json(response).await;
11440 assert_eq!(body["error"]["type"], "internal_server_error");
11441 assert!(body["error"]["message"]
11442 .as_str()
11443 .unwrap_or_default()
11444 .contains("stub stream failed"));
11445 }
11446
11447 #[tokio::test]
11448 async fn route_chat_stream_generation_failure_writes_replay_diagnostics() {
11449 let root = unique_request_dump_dir("chat-stream-start-failure");
11450 let response = post_json(
11451 router_with_failing_llm_and_request_dump_dir(root.clone()),
11452 "/v1/chat/completions",
11453 json!({
11454 "model": "failing-model",
11455 "messages": [{"role": "user", "content": "hello"}],
11456 "stream": true
11457 }),
11458 )
11459 .await;
11460 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11461 let body = response_json(response).await;
11462 assert_eq!(body["error"]["type"], "internal_server_error");
11463 assert!(body["error"]["message"]
11464 .as_str()
11465 .unwrap_or_default()
11466 .contains("stub stream failed"));
11467 assert_chat_failure_replay_bundle(
11468 &root,
11469 "chat_completions_stream_start",
11470 "internal",
11471 "stub stream failed",
11472 );
11473 let _ = fs::remove_dir_all(root);
11474 }
11475
11476 #[tokio::test]
11477 async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
11478 let response = post_json(
11479 router_with_stream_chunk_failing_llm(),
11480 "/v1/chat/completions",
11481 json!({
11482 "model": "failing-model",
11483 "messages": [{"role": "user", "content": "hello"}],
11484 "stream": true
11485 }),
11486 )
11487 .await;
11488 assert_eq!(response.status(), AxumStatusCode::OK);
11489 let body = response_text(response).await;
11490 assert_openai_stream_error(&body, "stub stream chunk failed");
11491 }
11492
11493 #[tokio::test]
11494 async fn route_chat_stream_chunk_failure_writes_replay_diagnostics() {
11495 let root = unique_request_dump_dir("chat-stream-chunk-failure");
11496 let response = post_json(
11497 router_with_stream_chunk_failing_llm_and_request_dump_dir(root.clone()),
11498 "/v1/chat/completions",
11499 json!({
11500 "model": "failing-model",
11501 "messages": [{"role": "user", "content": "hello"}],
11502 "stream": true
11503 }),
11504 )
11505 .await;
11506 assert_eq!(response.status(), AxumStatusCode::OK);
11507 let body = response_text(response).await;
11508 assert_openai_stream_error(&body, "stub stream chunk failed");
11509 assert_chat_failure_replay_bundle(
11510 &root,
11511 "chat_completions_stream_next",
11512 "internal",
11513 "stub stream chunk failed",
11514 );
11515 let _ = fs::remove_dir_all(root);
11516 }
11517
11518 #[tokio::test]
11519 async fn route_completions_engine_unavailable_maps_to_503() {
11520 let response = post_json(
11521 router_without_llm(),
11522 "/v1/completions",
11523 json!({
11524 "model": "stub-model",
11525 "prompt": "complete me"
11526 }),
11527 )
11528 .await;
11529 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11530 let body = response_json(response).await;
11531 assert_eq!(body["error"]["type"], "service_unavailable_error");
11532 assert_eq!(body["error"]["param"], Value::Null);
11533 }
11534
11535 #[tokio::test]
11536 async fn route_embeddings_engine_unavailable_maps_to_503() {
11537 let response = post_json(
11538 router_without_llm(),
11539 "/v1/embeddings",
11540 json!({
11541 "model": "embed-model",
11542 "input": "hello"
11543 }),
11544 )
11545 .await;
11546 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11547 let body = response_json(response).await;
11548 assert_eq!(body["error"]["type"], "service_unavailable_error");
11549 assert_eq!(body["error"]["param"], Value::Null);
11550 }
11551
11552 #[tokio::test]
11553 async fn route_embeddings_contract_uses_stub_engine() {
11554 let response = post_json(
11555 router_with_stub_embed(),
11556 "/v1/embeddings",
11557 json!({
11558 "model": "stub-embed",
11559 "input": ["hi", "world"],
11560 "encoding_format": "float"
11561 }),
11562 )
11563 .await;
11564 assert_eq!(response.status(), AxumStatusCode::OK);
11565 let body = response_json(response).await;
11566 assert_eq!(body["object"], "list");
11567 assert_eq!(body["model"], "stub-embed");
11568 assert_eq!(body["usage"]["prompt_tokens"], 7);
11569 assert_eq!(body["usage"]["total_tokens"], 7);
11570
11571 let data = body["data"].as_array().expect("embedding data");
11572 assert_eq!(data.len(), 2, "body: {body}");
11573 assert_eq!(data[0]["object"], "embedding");
11574 assert_eq!(data[0]["index"], 0);
11575 assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
11576 assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
11577 assert_eq!(data[1]["index"], 1);
11578 assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
11579 }
11580
11581 #[tokio::test]
11582 async fn route_embeddings_public_alias_succeeds_and_unknown_alias_is_rejected() {
11583 let registry = ServedModelRegistry::try_new(
11584 "stub-embed",
11585 ServedModelKind::Embedding,
11586 vec!["public-embed".to_string()],
11587 vec![],
11588 )
11589 .unwrap();
11590 let server =
11591 AxumServer::from_embed(Arc::new(StubEmbed::new())).with_served_model_registry(registry);
11592 let accepted = post_json(
11593 server.build_router(),
11594 "/v1/embeddings",
11595 json!({"model": "public-embed", "input": "hello"}),
11596 )
11597 .await;
11598 assert_eq!(accepted.status(), AxumStatusCode::OK);
11599 assert_eq!(response_json(accepted).await["model"], "public-embed");
11600
11601 let rejected = post_json(
11602 server.build_router(),
11603 "/v1/embeddings",
11604 json!({"model": "stub-embed", "input": "hello"}),
11605 )
11606 .await;
11607 assert_eq!(rejected.status(), AxumStatusCode::BAD_REQUEST);
11608 let body = response_json(rejected).await;
11609 assert_eq!(body["error"]["type"], "invalid_request_error");
11610 assert_eq!(body["error"]["param"], "model");
11611 }
11612
11613 #[tokio::test]
11614 async fn route_embeddings_rejects_unsupported_encoding_format() {
11615 let response = post_json(
11616 router_with_stub_embed(),
11617 "/v1/embeddings",
11618 json!({
11619 "model": "stub-embed",
11620 "input": "hi",
11621 "encoding_format": "base64"
11622 }),
11623 )
11624 .await;
11625 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11626 let body = response_json(response).await;
11627 assert_eq!(body["error"]["type"], "invalid_request_error");
11628 assert_eq!(body["error"]["param"], "encoding_format");
11629 }
11630
11631 #[tokio::test]
11632 async fn route_embeddings_rejects_empty_input_with_field_param() {
11633 let response = post_json(
11634 router_with_stub_embed(),
11635 "/v1/embeddings",
11636 json!({
11637 "model": "stub-embed",
11638 "input": []
11639 }),
11640 )
11641 .await;
11642 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11643 let body = response_json(response).await;
11644 assert_eq!(body["error"]["type"], "invalid_request_error");
11645 assert_eq!(body["error"]["param"], "input");
11646 }
11647
11648 #[tokio::test]
11649 async fn route_embeddings_rejects_empty_item_with_field_param() {
11650 let response = post_json(
11651 router_with_stub_embed(),
11652 "/v1/embeddings",
11653 json!({
11654 "model": "stub-embed",
11655 "input": [{}]
11656 }),
11657 )
11658 .await;
11659 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11660 let body = response_json(response).await;
11661 assert_eq!(body["error"]["type"], "invalid_request_error");
11662 assert_eq!(body["error"]["param"], "input");
11663 }
11664
11665 #[tokio::test]
11666 async fn route_embeddings_invalid_json_maps_to_openai_error() {
11667 let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
11668 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11669 let body = response_json(response).await;
11670 assert_eq!(body["error"]["type"], "invalid_request_error");
11671 assert_eq!(body["error"]["param"], Value::Null);
11672 assert!(body["error"]["message"]
11673 .as_str()
11674 .unwrap()
11675 .contains("invalid embeddings request"));
11676 }
11677
11678 #[tokio::test]
11679 async fn route_transcriptions_engine_unavailable_maps_to_503() {
11680 let boundary = "ferrum-test-boundary";
11681 let body = concat!(
11682 "--ferrum-test-boundary\r\n",
11683 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
11684 "Content-Type: audio/wav\r\n",
11685 "\r\n",
11686 "RIFFtest\r\n",
11687 "--ferrum-test-boundary--\r\n"
11688 );
11689 let response = post_multipart(
11690 router_without_llm(),
11691 "/v1/audio/transcriptions",
11692 boundary,
11693 body,
11694 )
11695 .await;
11696 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11697 let body = response_json(response).await;
11698 assert_eq!(body["error"]["type"], "service_unavailable_error");
11699 assert_eq!(body["error"]["param"], Value::Null);
11700 }
11701
11702 #[tokio::test]
11703 async fn route_transcriptions_contract_uses_stub_engine() {
11704 let boundary = "ferrum-test-boundary";
11705 let body = concat!(
11706 "--ferrum-test-boundary\r\n",
11707 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
11708 "Content-Type: audio/wav\r\n",
11709 "\r\n",
11710 "RIFFtest\r\n",
11711 "--ferrum-test-boundary\r\n",
11712 "Content-Disposition: form-data; name=\"language\"\r\n",
11713 "\r\n",
11714 "en\r\n",
11715 "--ferrum-test-boundary\r\n",
11716 "Content-Disposition: form-data; name=\"response_format\"\r\n",
11717 "\r\n",
11718 "json\r\n",
11719 "--ferrum-test-boundary--\r\n"
11720 );
11721 let response = post_multipart(
11722 router_with_stub_transcribe(),
11723 "/v1/audio/transcriptions",
11724 boundary,
11725 body,
11726 )
11727 .await;
11728 assert_eq!(response.status(), AxumStatusCode::OK);
11729 let body = response_json(response).await;
11730 assert_eq!(body["text"], "bytes:8:en");
11731 }
11732
11733 #[tokio::test]
11734 async fn route_transcriptions_rejects_unsupported_response_format() {
11735 let boundary = "ferrum-test-boundary";
11736 let body = concat!(
11737 "--ferrum-test-boundary\r\n",
11738 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
11739 "Content-Type: audio/wav\r\n",
11740 "\r\n",
11741 "RIFFtest\r\n",
11742 "--ferrum-test-boundary\r\n",
11743 "Content-Disposition: form-data; name=\"response_format\"\r\n",
11744 "\r\n",
11745 "text\r\n",
11746 "--ferrum-test-boundary--\r\n"
11747 );
11748 let response = post_multipart(
11749 router_with_stub_transcribe(),
11750 "/v1/audio/transcriptions",
11751 boundary,
11752 body,
11753 )
11754 .await;
11755 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11756 let body = response_json(response).await;
11757 assert_eq!(body["error"]["type"], "invalid_request_error");
11758 assert_eq!(body["error"]["param"], "response_format");
11759 }
11760
11761 #[tokio::test]
11762 async fn route_transcriptions_rejects_missing_file_with_field_param() {
11763 let boundary = "ferrum-test-boundary";
11764 let body = concat!(
11765 "--ferrum-test-boundary\r\n",
11766 "Content-Disposition: form-data; name=\"language\"\r\n",
11767 "\r\n",
11768 "en\r\n",
11769 "--ferrum-test-boundary--\r\n"
11770 );
11771 let response = post_multipart(
11772 router_with_stub_transcribe(),
11773 "/v1/audio/transcriptions",
11774 boundary,
11775 body,
11776 )
11777 .await;
11778 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11779 let body = response_json(response).await;
11780 assert_eq!(body["error"]["type"], "invalid_request_error");
11781 assert_eq!(body["error"]["param"], "file");
11782 }
11783
11784 #[tokio::test]
11785 async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
11786 let response = post_json(
11787 router_with_stub_transcribe(),
11788 "/v1/audio/transcriptions",
11789 json!({}),
11790 )
11791 .await;
11792 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11793 let body = response_json(response).await;
11794 assert_eq!(body["error"]["type"], "invalid_request_error");
11795 assert_eq!(body["error"]["param"], Value::Null);
11796 assert!(body["error"]["message"]
11797 .as_str()
11798 .unwrap()
11799 .contains("invalid transcriptions request"));
11800 }
11801
11802 #[tokio::test]
11803 async fn route_speech_engine_unavailable_maps_to_503() {
11804 let response = post_json(
11805 router_without_llm(),
11806 "/v1/audio/speech",
11807 json!({
11808 "model": "tts-model",
11809 "input": "hello",
11810 "voice": "default"
11811 }),
11812 )
11813 .await;
11814 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11815 let body = response_json(response).await;
11816 assert_eq!(body["error"]["type"], "service_unavailable_error");
11817 assert_eq!(body["error"]["param"], Value::Null);
11818 }
11819
11820 #[tokio::test]
11821 async fn route_speech_contract_uses_stub_engine() {
11822 let response = post_json(
11823 router_with_stub_tts(),
11824 "/v1/audio/speech",
11825 json!({
11826 "model": "stub-tts",
11827 "input": "hello",
11828 "voice": "default",
11829 "response_format": "wav",
11830 "language": "english"
11831 }),
11832 )
11833 .await;
11834 assert_eq!(response.status(), AxumStatusCode::OK);
11835 assert_eq!(
11836 response.headers().get(header::CONTENT_TYPE).unwrap(),
11837 "audio/wav"
11838 );
11839 let body = response_bytes(response).await;
11840 assert!(body.len() > 44, "WAV should include header and PCM data");
11841 assert_eq!(&body[0..4], b"RIFF");
11842 assert_eq!(&body[8..12], b"WAVE");
11843 }
11844
11845 #[tokio::test]
11846 async fn route_speech_streaming_contract_uses_stub_engine() {
11847 let response = post_json(
11848 router_with_stub_tts(),
11849 "/v1/audio/speech",
11850 json!({
11851 "model": "stub-tts",
11852 "input": "hello",
11853 "voice": "default",
11854 "response_format": "wav",
11855 "stream": true
11856 }),
11857 )
11858 .await;
11859 assert_eq!(response.status(), AxumStatusCode::OK);
11860 assert_eq!(
11861 response.headers().get(header::CONTENT_TYPE).unwrap(),
11862 "audio/wav"
11863 );
11864 assert_eq!(
11865 response.headers().get(header::TRANSFER_ENCODING).unwrap(),
11866 "chunked"
11867 );
11868 let body = response_bytes(response).await;
11869 assert!(body.len() > 44, "streaming WAV should include audio bytes");
11870 assert_eq!(&body[0..4], b"RIFF");
11871 assert_eq!(&body[8..12], b"WAVE");
11872 }
11873
11874 #[tokio::test]
11875 async fn route_speech_pcm_response_format_returns_raw_pcm() {
11876 let response = post_json(
11877 router_with_stub_tts(),
11878 "/v1/audio/speech",
11879 json!({
11880 "model": "stub-tts",
11881 "input": "hello",
11882 "voice": "default",
11883 "response_format": "pcm"
11884 }),
11885 )
11886 .await;
11887 assert_eq!(response.status(), AxumStatusCode::OK);
11888 assert_eq!(
11889 response.headers().get(header::CONTENT_TYPE).unwrap(),
11890 "audio/pcm"
11891 );
11892 let body = response_bytes(response).await;
11893 assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
11894 assert_eq!(&body[0..2], &[0, 0]);
11895 assert_ne!(&body[0..4], b"RIFF");
11896 }
11897
11898 #[tokio::test]
11899 async fn route_speech_rejects_unsupported_response_format() {
11900 let response = post_json(
11901 router_with_stub_tts(),
11902 "/v1/audio/speech",
11903 json!({
11904 "model": "stub-tts",
11905 "input": "hello",
11906 "voice": "default",
11907 "response_format": "mp3"
11908 }),
11909 )
11910 .await;
11911 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11912 let body = response_json(response).await;
11913 assert_eq!(body["error"]["type"], "invalid_request_error");
11914 assert_eq!(body["error"]["param"], "response_format");
11915 }
11916
11917 #[tokio::test]
11918 async fn route_speech_invalid_json_maps_to_openai_error() {
11919 let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
11920 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11921 let body = response_json(response).await;
11922 assert_eq!(body["error"]["type"], "invalid_request_error");
11923 assert_eq!(body["error"]["param"], Value::Null);
11924 assert!(body["error"]["message"]
11925 .as_str()
11926 .unwrap()
11927 .contains("invalid speech request"));
11928 }
11929
11930 #[tokio::test]
11931 async fn route_completions_generation_failure_maps_to_500() {
11932 let response = post_json(
11933 router_with_failing_llm(),
11934 "/v1/completions",
11935 json!({
11936 "model": "failing-model",
11937 "prompt": "complete me"
11938 }),
11939 )
11940 .await;
11941 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11942 let body = response_json(response).await;
11943 assert_eq!(body["error"]["type"], "internal_server_error");
11944 assert!(body["error"]["message"]
11945 .as_str()
11946 .unwrap()
11947 .contains("stub generation failed"));
11948 }
11949
11950 #[tokio::test]
11951 async fn route_completions_stream_generation_failure_emits_openai_error_event() {
11952 let response = post_json(
11953 router_with_failing_llm(),
11954 "/v1/completions",
11955 json!({
11956 "model": "failing-model",
11957 "prompt": "complete me",
11958 "stream": true
11959 }),
11960 )
11961 .await;
11962 assert_eq!(response.status(), AxumStatusCode::OK);
11963 let body = response_text(response).await;
11964 assert_openai_stream_error(&body, "stub stream failed");
11965 }
11966
11967 #[tokio::test]
11968 async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
11969 let response = post_json(
11970 router_with_stream_chunk_failing_llm(),
11971 "/v1/completions",
11972 json!({
11973 "model": "failing-model",
11974 "prompt": "complete me",
11975 "stream": true
11976 }),
11977 )
11978 .await;
11979 assert_eq!(response.status(), AxumStatusCode::OK);
11980 let body = response_text(response).await;
11981 assert_openai_stream_error(&body, "stub stream chunk failed");
11982 }
11983
11984 #[tokio::test]
11985 async fn route_completions_contract_uses_stub_engine() {
11986 let response = post_json(
11987 router_with_stub("done"),
11988 "/v1/completions",
11989 json!({
11990 "model": "stub-model",
11991 "prompt": "complete me",
11992 "max_tokens": 8,
11993 "temperature": 0.0
11994 }),
11995 )
11996 .await;
11997 assert_eq!(response.status(), AxumStatusCode::OK);
11998 let body = response_json(response).await;
11999 assert_eq!(body["object"], "text_completion");
12000 assert_eq!(body["choices"][0]["text"], "done");
12001 assert_eq!(body["usage"]["prompt_tokens"], 7);
12002 assert_eq!(body["usage"]["completion_tokens"], 2);
12003 }
12004
12005 #[tokio::test]
12006 async fn route_completions_public_alias_maps_to_internal_model() {
12007 let engine = Arc::new(CapturingLlm::new());
12008 let registry = ServedModelRegistry::try_new(
12009 "qwen3",
12010 ServedModelKind::Llm,
12011 vec!["served-alias".to_string()],
12012 vec![],
12013 )
12014 .unwrap();
12015 let router = AxumServer::from_llm(engine.clone())
12016 .with_served_model_registry(registry)
12017 .build_router();
12018 let response = post_json(
12019 router,
12020 "/v1/completions",
12021 json!({"model": "served-alias", "prompt": "complete me"}),
12022 )
12023 .await;
12024
12025 assert_eq!(response.status(), AxumStatusCode::OK);
12026 assert_eq!(response_json(response).await["model"], "served-alias");
12027 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
12028 }
12029
12030 #[tokio::test]
12031 async fn route_completions_streaming_contract_uses_stub_engine() {
12032 let response = post_json(
12033 router_with_stub("done"),
12034 "/v1/completions",
12035 json!({
12036 "model": "stub-model",
12037 "prompt": "complete me",
12038 "max_tokens": 8,
12039 "temperature": 0.0,
12040 "stream": true
12041 }),
12042 )
12043 .await;
12044 assert_eq!(response.status(), AxumStatusCode::OK);
12045 let body = response_text(response).await;
12046 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12047 assert!(
12048 body.contains("\"object\":\"text_completion\""),
12049 "missing completion chunk: {body}"
12050 );
12051 assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
12052 assert!(
12053 body.contains("\"choices\":[],\"usage\""),
12054 "missing separate usage chunk: {body}"
12055 );
12056 assert!(
12057 body.contains("\"prompt_tokens\":5"),
12058 "stream usage should come from engine token usage: {body}"
12059 );
12060 assert!(
12061 body.contains("\"completion_tokens\":1"),
12062 "stream completion usage should come from engine token usage: {body}"
12063 );
12064 }
12065
12066 #[tokio::test]
12067 async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
12068 let response = post_json(
12069 router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
12070 "/v1/completions",
12071 json!({
12072 "model": "stub-model",
12073 "prompt": "complete me",
12074 "max_tokens": 2,
12075 "temperature": 0.0,
12076 "stream": true
12077 }),
12078 )
12079 .await;
12080 assert_eq!(response.status(), AxumStatusCode::OK);
12081 let body = response_text(response).await;
12082 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
12083 assert!(
12084 body.contains("\"text\":\"do\""),
12085 "missing first chunk: {body}"
12086 );
12087 assert!(
12088 body.contains("\"text\":\"ne\""),
12089 "missing second chunk: {body}"
12090 );
12091 assert!(
12092 body.contains("\"choices\":[],\"usage\""),
12093 "missing separate usage chunk from final engine chunk: {body}"
12094 );
12095 assert!(
12096 body.contains("\"prompt_tokens\":5"),
12097 "stream usage should come from engine final usage: {body}"
12098 );
12099 }
12100
12101 #[tokio::test]
12102 async fn route_completions_invalid_json_maps_to_openai_error() {
12103 let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
12104 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12105 let body = response_json(response).await;
12106 assert_eq!(body["error"]["type"], "invalid_request_error");
12107 assert_eq!(body["error"]["param"], Value::Null);
12108 assert!(body["error"]["message"]
12109 .as_str()
12110 .unwrap()
12111 .contains("invalid completions request"));
12112 }
12113
12114 #[tokio::test]
12115 async fn route_completions_rejects_unsupported_fields_explicitly() {
12116 for (extra, param) in [
12117 (json!({"n": 2}), "n"),
12118 (json!({"logprobs": 3}), "logprobs"),
12119 (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
12120 ] {
12121 let mut body = json!({
12122 "model": "stub-model",
12123 "prompt": "complete me"
12124 });
12125 body.as_object_mut()
12126 .expect("object")
12127 .extend(extra.as_object().expect("extra object").clone());
12128 let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
12129 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12130 let body = response_json(response).await;
12131 assert_eq!(body["error"]["type"], "invalid_request_error");
12132 assert_eq!(body["error"]["param"], param);
12133 }
12134 }
12135
12136 #[tokio::test]
12137 async fn streaming_completions_do_not_synthesize_whitespace_usage() {
12138 let response = post_json(
12139 router_with_stub_without_stream_usage("done"),
12140 "/v1/completions",
12141 json!({
12142 "model": "stub-model",
12143 "prompt": "one two three four",
12144 "stream": true
12145 }),
12146 )
12147 .await;
12148 assert_eq!(response.status(), AxumStatusCode::OK);
12149 let body = response_text(response).await;
12150 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12151 assert!(
12152 !body.contains("\"usage\":{\"prompt_tokens\""),
12153 "server must not synthesize whitespace-count completion usage: {body}"
12154 );
12155 }
12156
12157 #[tokio::test]
12158 async fn chat_rejects_n_not_one_with_openai_error_param() {
12159 let request = chat_request(json!({"n": 2}));
12160 let err = chat_completions_handler(
12161 State(state_with_stub("unused")),
12162 HeaderMap::new(),
12163 Ok(Json(request)),
12164 )
12165 .await
12166 .expect_err("n=2 should reject");
12167 let (status, body) = error_json(err).await;
12168 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12169 assert_eq!(body["error"]["type"], "invalid_request_error");
12170 assert_eq!(body["error"]["param"], "n");
12171 }
12172
12173 #[tokio::test]
12174 async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
12175 for (extra, param) in [
12176 (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
12177 (json!({"logprobs": true}), "logprobs"),
12178 (json!({"top_logprobs": 2}), "top_logprobs"),
12179 ] {
12180 let request = chat_request(extra);
12181 let err = chat_completions_handler(
12182 State(state_with_stub("unused")),
12183 HeaderMap::new(),
12184 Ok(Json(request)),
12185 )
12186 .await
12187 .expect_err("unsupported field should reject");
12188 let (status, body) = error_json(err).await;
12189 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12190 assert_eq!(body["error"]["param"], param);
12191 assert_eq!(body["error"]["type"], "invalid_request_error");
12192 }
12193 }
12194
12195 #[tokio::test]
12196 async fn chat_stream_options_include_usage_controls_stream_usage() {
12197 let request = chat_request(json!({
12198 "stream": true,
12199 "stream_options": {"include_usage": true}
12200 }));
12201 let response = chat_completions_handler(
12202 State(state_with_stub("ok")),
12203 HeaderMap::new(),
12204 Ok(Json(request)),
12205 )
12206 .await
12207 .expect("stream response");
12208 assert_eq!(response.status(), AxumStatusCode::OK);
12209 let body = response_text(response).await;
12210 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12211 assert!(
12212 body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
12213 "include_usage=true should emit stream usage: {body}"
12214 );
12215 assert!(
12216 body.contains("\"choices\":[],\"usage\""),
12217 "include_usage=true should use a separate usage chunk: {body}"
12218 );
12219 assert!(
12220 body.contains("\"prompt_tokens\":5"),
12221 "stream usage should come from engine token usage: {body}"
12222 );
12223
12224 let request = chat_request(json!({"stream": true}));
12225 let response = chat_completions_handler(
12226 State(state_with_stub("ok")),
12227 HeaderMap::new(),
12228 Ok(Json(request)),
12229 )
12230 .await
12231 .expect("stream response");
12232 let body = response_text(response).await;
12233 assert!(
12234 !body.contains("\"usage\":{\"prompt_tokens\""),
12235 "stream usage should be omitted unless requested: {body}"
12236 );
12237 }
12238
12239 #[tokio::test]
12240 async fn streaming_chat_does_not_synthesize_whitespace_usage() {
12241 let response = post_json(
12242 router_with_stub_without_stream_usage("ok"),
12243 "/v1/chat/completions",
12244 json!({
12245 "model": "stub-model",
12246 "messages": [{"role": "user", "content": "one two three four"}],
12247 "stream": true,
12248 "stream_options": {"include_usage": true}
12249 }),
12250 )
12251 .await;
12252 assert_eq!(response.status(), AxumStatusCode::OK);
12253 let body = response_text(response).await;
12254 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12255 assert!(
12256 !body.contains("\"usage\":{\"prompt_tokens\""),
12257 "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
12258 );
12259 }
12260
12261 #[test]
12262 fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
12263 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12264 "model": "qwen3",
12265 "messages": [
12266 {"role": "user", "content": "Use the weather tool."},
12267 {
12268 "role": "assistant",
12269 "content": null,
12270 "tool_calls": [{
12271 "id": "call_1",
12272 "type": "function",
12273 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
12274 }]
12275 },
12276 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
12277 ],
12278 "tools": [{
12279 "type": "function",
12280 "function": {
12281 "name": "weather",
12282 "description": "Get weather",
12283 "parameters": {
12284 "type": "object",
12285 "properties": {"city": {"type": "string"}},
12286 "required": ["city"]
12287 }
12288 }
12289 }],
12290 "tool_choice": "auto"
12291 }))
12292 .expect("tool request parses");
12293
12294 validate_chat_request(&request).expect("tool request validates");
12295 let internal = convert_chat_request(&request).expect("convert");
12296 assert!(internal.prompt.contains("\"tools\":[{"));
12297 assert!(internal.prompt.contains("\"type\":\"function\""));
12298 assert!(internal.prompt.contains("\"name\":\"weather\""));
12299 assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
12300 assert!(internal.prompt.contains("\"tool_calls\":[{"));
12301 assert!(internal.prompt.contains("\"id\":\"call_1\""));
12302 assert!(internal
12303 .prompt
12304 .contains("<|im_start|>tool\nsunny<|im_end|>"));
12305 assert_eq!(
12306 internal.metadata["openai_tools"][0]["function"]["name"],
12307 "weather"
12308 );
12309 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12310 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12311 panic!("expected structured chat api_request");
12312 };
12313 assert_eq!(api.messages.len(), 3);
12314 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
12315 assert_eq!(api.messages[2].content, "sunny");
12316 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
12317 assert_eq!(api.tools[0].function.name, "weather");
12318 assert_eq!(
12319 api.tool_choice,
12320 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12321 );
12322 assert_eq!(
12323 api.messages[1].tool_calls[0].function.arguments,
12324 "{\"city\":\"Paris\"}"
12325 );
12326 }
12327
12328 #[test]
12329 fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
12330 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12331 "model": "served-alias",
12332 "messages": [{"role": "user", "content": "Use the weather tool."}],
12333 "tools": [{
12334 "type": "function",
12335 "function": {
12336 "name": "weather",
12337 "description": "Get weather",
12338 "parameters": {
12339 "type": "object",
12340 "properties": {"city": {"type": "string"}},
12341 "required": ["city"]
12342 }
12343 }
12344 }]
12345 }))
12346 .expect("tool request parses");
12347
12348 validate_chat_request(&request).expect("tool request validates");
12349 let internal = convert_chat_request(&request).expect("convert");
12350 assert!(internal.prompt.contains("\"tools\":[{"));
12351 assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
12352 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12353 let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
12354 .as_array()
12355 .expect("initial forbidden token list");
12356 assert_eq!(initial_forbidden, &[serde_json::json!(THINK_END_TAG)]);
12357 assert_eq!(
12358 internal.sampling_params.response_format,
12359 ferrum_types::ResponseFormat::Text,
12360 "auto tool choice must preserve native model selection instead of forcing arguments JSON",
12361 );
12362 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12363 panic!("expected structured chat api_request");
12364 };
12365 assert_eq!(
12366 api.tool_choice,
12367 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12368 );
12369 }
12370
12371 #[test]
12372 fn omitted_tool_choice_uses_native_template_protocol_without_hard_schema() {
12373 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12374 "model": "served-alias",
12375 "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
12376 "tools": [{
12377 "type": "function",
12378 "function": {
12379 "name": "get_weather",
12380 "description": "查询指定城市的当前天气",
12381 "parameters": {
12382 "type": "object",
12383 "properties": {
12384 "city": {"type": "string"},
12385 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12386 },
12387 "required": ["city"]
12388 }
12389 }
12390 }]
12391 }))
12392 .expect("tool request parses");
12393 let template = ModelChatTemplate::new(
12394 "{% 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 %}",
12395 "function-parameter-xml-template",
12396 );
12397
12398 validate_chat_request(&request).expect("tool request validates");
12399 let internal =
12400 convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
12401 .expect("convert");
12402 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12403 assert_eq!(
12404 internal.sampling_params.response_format,
12405 ferrum_types::ResponseFormat::Text,
12406 );
12407 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
12408 panic!("expected chat API request");
12409 };
12410 assert_eq!(
12411 api.tool_call_protocol,
12412 ferrum_types::ApiToolCallProtocol::FunctionParameterXml,
12413 );
12414 }
12415
12416 #[test]
12417 fn tool_schema_response_format_bounds_unconstrained_strings() {
12418 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12419 "model": "served-alias",
12420 "messages": [{"role": "user", "content": "Use the selected tool."}],
12421 "tools": [{
12422 "type": "function",
12423 "function": {
12424 "name": "get_weather",
12425 "parameters": {
12426 "type": "object",
12427 "properties": {
12428 "city": {"type": "string"},
12429 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12430 },
12431 "required": ["city"]
12432 }
12433 }
12434 }],
12435 "tool_choice": {
12436 "type": "function",
12437 "function": {"name": "get_weather"}
12438 }
12439 }))
12440 .expect("tool request parses");
12441
12442 validate_chat_request(&request).expect("tool request validates");
12443 let internal = convert_chat_request(&request).expect("convert");
12444 match internal.sampling_params.response_format {
12445 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
12446 let value: serde_json::Value =
12447 serde_json::from_str(schema).expect("schema should be JSON");
12448 assert_eq!(
12449 value["properties"]["city"]["maxLength"],
12450 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
12451 );
12452 assert_eq!(
12453 value["properties"]["unit"]["enum"],
12454 json!(["celsius", "fahrenheit"])
12455 );
12456 assert!(
12457 value["properties"]["unit"]["maxLength"].is_null(),
12458 "enum string should remain finite via enum instead of maxLength: {value}"
12459 );
12460 }
12461 ref other => panic!("expected forced tool json schema, got {other:?}"),
12462 }
12463 }
12464
12465 #[test]
12466 fn harmony_named_tool_choice_preserves_native_protocol_envelope() {
12467 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12468 "model": "gpt-oss-20b-mxfp4",
12469 "messages": [{
12470 "role": "user",
12471 "content": "Call get_weather exactly once with city set to Paris."
12472 }],
12473 "tools": [{
12474 "type": "function",
12475 "function": {
12476 "name": "get_weather",
12477 "parameters": {
12478 "type": "object",
12479 "properties": {"city": {"type": "string"}},
12480 "required": ["city"],
12481 "additionalProperties": false
12482 }
12483 }
12484 }],
12485 "tool_choice": {
12486 "type": "function",
12487 "function": {"name": "get_weather"}
12488 }
12489 }))
12490 .expect("Harmony tool request parses");
12491 let mut template = ModelChatTemplate::new(
12492 "{% if tools %}<|start|>developer<|message|>{{ tools | tojson }}<|end|>{% endif %}{% for message in messages %}<|start|>{{ message.role }}<|message|>{{ message.content }}<|end|>{% endfor %}{% if add_generation_prompt %}<|start|>assistant{% endif %}",
12493 "harmony-tool-template",
12494 );
12495 template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
12496
12497 validate_chat_request(&request).expect("Harmony tool request validates");
12498 let internal =
12499 convert_chat_request_with_template_model(&request, "gpt-oss-20b", Some(&template))
12500 .expect("convert Harmony tool request");
12501
12502 assert!(internal.prompt.ends_with("<|start|>assistant"));
12503 assert_eq!(
12504 internal.sampling_params.model_output_protocol,
12505 ModelOutputProtocol::HarmonyGptOss
12506 );
12507 assert_eq!(
12508 internal.sampling_params.response_format,
12509 ferrum_types::ResponseFormat::Text,
12510 "Harmony must generate its channel/message/call envelope before tool arguments"
12511 );
12512 assert_eq!(
12513 internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY],
12514 json!([]),
12515 "Harmony declares no think delimiter and must not receive the generic structured-call mask"
12516 );
12517 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
12518 panic!("expected chat API request");
12519 };
12520 assert_eq!(
12521 api.tool_choice,
12522 Some(ferrum_types::ApiToolChoice::Function {
12523 tool_type: "function".to_string(),
12524 function: ferrum_types::ApiToolChoiceFunction {
12525 name: "get_weather".to_string(),
12526 },
12527 })
12528 );
12529 }
12530
12531 #[test]
12532 fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
12533 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12534 "model": "served-alias",
12535 "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
12536 "tools": [{
12537 "type": "function",
12538 "function": {
12539 "name": "capture_quality_marker",
12540 "description": "Record one marker.",
12541 "parameters": {
12542 "type": "object",
12543 "properties": {
12544 "marker": {"type": "string", "enum": ["ferrum0401"]},
12545 "checksum": {"type": "string", "enum": ["S0004"]}
12546 },
12547 "required": ["marker", "checksum"]
12548 }
12549 }
12550 }],
12551 "tool_choice": "required"
12552 }))
12553 .expect("tool request parses");
12554
12555 validate_chat_request(&request).expect("tool request validates");
12556 let internal = convert_chat_request(&request).expect("convert");
12557
12558 assert!(
12559 !internal.prompt.contains(
12560 "Output only a single JSON object containing the selected function arguments"
12561 ),
12562 "{}",
12563 internal.prompt
12564 );
12565 assert!(
12566 internal.prompt.contains("\"tool_choice\":\"required\""),
12567 "{}",
12568 internal.prompt
12569 );
12570 assert_eq!(internal.metadata["openai_tool_choice"], "required");
12571 match internal.sampling_params.response_format {
12572 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
12573 assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
12574 assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
12575 }
12576 ref other => panic!("expected forced tool json schema, got {other:?}"),
12577 }
12578 }
12579
12580 #[test]
12581 fn required_tool_choice_suppresses_conflicting_response_format_instruction() {
12582 let request: ChatCompletionsRequest =
12583 serde_json::from_value(required_tool_with_strict_response_format_request(false))
12584 .expect("request parses");
12585
12586 validate_chat_request(&request).expect("request validates");
12587 let internal = convert_chat_request(&request).expect("convert");
12588
12589 assert!(
12590 !internal.prompt.contains("response_format requires"),
12591 "required tool output must not receive a conflicting content-schema instruction: {}",
12592 internal.prompt
12593 );
12594 let ferrum_types::ResponseFormat::JsonSchema(schema) =
12595 internal.sampling_params.response_format
12596 else {
12597 panic!("single required tool must use its argument schema");
12598 };
12599 let schema: Value = serde_json::from_str(&schema).expect("tool schema JSON");
12600 assert!(schema["properties"].get("city").is_some(), "{schema}");
12601 assert!(schema["properties"].get("answer").is_none(), "{schema}");
12602 }
12603
12604 #[test]
12605 fn required_multiple_tools_do_not_force_the_first_tool_schema() {
12606 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12607 "model": "stub-model",
12608 "messages": [{"role": "user", "content": "Use the appropriate tool."}],
12609 "tools": [
12610 {
12611 "type": "function",
12612 "function": {
12613 "name": "weather",
12614 "parameters": {
12615 "type": "object",
12616 "properties": {"city": {"type": "string"}},
12617 "required": ["city"]
12618 }
12619 }
12620 },
12621 {
12622 "type": "function",
12623 "function": {
12624 "name": "calendar",
12625 "parameters": {
12626 "type": "object",
12627 "properties": {"date": {"type": "string"}},
12628 "required": ["date"]
12629 }
12630 }
12631 }
12632 ],
12633 "tool_choice": "required"
12634 }))
12635 .expect("request parses");
12636
12637 validate_chat_request(&request).expect("request validates");
12638 let internal = convert_chat_request(&request).expect("convert");
12639 assert_eq!(
12640 internal.sampling_params.response_format,
12641 ferrum_types::ResponseFormat::Text,
12642 "required permits either declared tool, so guided decoding cannot bind the first tool's arguments"
12643 );
12644 }
12645
12646 #[test]
12647 fn omitted_single_unrelated_tool_keeps_text_response_format() {
12648 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12649 "model": "served-alias",
12650 "messages": [{"role": "user", "content": "讲一个短笑话。"}],
12651 "tools": [{
12652 "type": "function",
12653 "function": {
12654 "name": "get_weather",
12655 "description": "查询指定城市的当前天气",
12656 "parameters": {
12657 "type": "object",
12658 "properties": {"city": {"type": "string"}},
12659 "required": ["city"]
12660 }
12661 }
12662 }]
12663 }))
12664 .expect("tool request parses");
12665
12666 validate_chat_request(&request).expect("tool request validates");
12667 let internal = convert_chat_request(&request).expect("convert");
12668 assert_eq!(
12669 internal.sampling_params.response_format,
12670 ferrum_types::ResponseFormat::Text
12671 );
12672 }
12673
12674 #[test]
12675 fn tool_choice_none_omits_tools_from_model_template_prompt() {
12676 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12677 "model": "served-alias",
12678 "messages": [
12679 {"role": "user", "content": "Use the weather tool if needed."},
12680 {
12681 "role": "assistant",
12682 "content": null,
12683 "tool_calls": [{
12684 "id": "call_1",
12685 "type": "function",
12686 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
12687 }]
12688 },
12689 {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
12690 ],
12691 "tools": [{
12692 "type": "function",
12693 "function": {"name": "weather", "parameters": {"type": "object"}}
12694 }],
12695 "tool_choice": "none"
12696 }))
12697 .expect("tool_choice none request parses");
12698 let template = ModelChatTemplate::new(
12699 "{% 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 %}",
12700 "tool-choice-none-template",
12701 );
12702
12703 validate_chat_request(&request).expect("tool_choice none request validates");
12704 let internal = convert_chat_request_with_template_model(
12705 &request,
12706 "served-template-model",
12707 Some(&template),
12708 )
12709 .expect("convert");
12710 assert!(
12711 !internal.prompt.contains("<tools>"),
12712 "tool_choice none must not expose tools to the model template: {}",
12713 internal.prompt
12714 );
12715 assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
12716 assert_eq!(
12717 internal.metadata["openai_tools"][0]["function"]["name"],
12718 "weather"
12719 );
12720 assert_eq!(internal.metadata["openai_tool_choice"], "none");
12721 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12722 panic!("expected structured chat api_request");
12723 };
12724 assert_eq!(api.tools[0].function.name, "weather");
12725 assert_eq!(
12726 api.tool_choice,
12727 Some(ferrum_types::ApiToolChoice::Mode("none".into()))
12728 );
12729 }
12730
12731 #[test]
12732 fn specific_tool_choice_parses_into_structured_api_request() {
12733 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12734 "model": "qwen3",
12735 "messages": [{"role": "user", "content": "Use the selected tool."}],
12736 "tools": [
12737 {
12738 "type": "function",
12739 "function": {"name": "weather", "parameters": {"type": "object"}}
12740 },
12741 {
12742 "type": "function",
12743 "function": {"name": "calendar", "parameters": {"type": "object"}}
12744 }
12745 ],
12746 "tool_choice": {
12747 "type": "function",
12748 "function": {"name": "weather"}
12749 }
12750 }))
12751 .expect("specific tool_choice request parses");
12752
12753 validate_chat_request(&request).expect("specific tool_choice validates");
12754 let internal = convert_chat_request(&request).expect("convert");
12755 assert!(internal.prompt.contains("\"tool_choice\":{"));
12756 assert!(internal.prompt.contains("\"name\":\"weather\""));
12757 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12758 panic!("expected structured chat api_request");
12759 };
12760 assert_eq!(
12761 api.tool_choice,
12762 Some(ferrum_types::ApiToolChoice::Function {
12763 tool_type: "function".to_string(),
12764 function: ferrum_types::ApiToolChoiceFunction {
12765 name: "weather".to_string()
12766 },
12767 })
12768 );
12769
12770 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
12771 "model": "qwen3",
12772 "messages": [{"role": "user", "content": "Use the selected tool."}],
12773 "tools": [{
12774 "type": "function",
12775 "function": {"name": "weather", "parameters": {"type": "object"}}
12776 }],
12777 "tool_choice": {
12778 "type": "function",
12779 "function": {"name": "calendar"}
12780 }
12781 }))
12782 .expect("invalid specific tool_choice request parses");
12783 let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
12784 match err {
12785 ServerError::InvalidRequest { param, .. } => {
12786 assert_eq!(param.as_deref(), Some("tool_choice"));
12787 }
12788 other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
12789 }
12790 }
12791
12792 #[test]
12793 fn legacy_function_role_messages_parse_into_structured_api_request() {
12794 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12795 "model": "mystery-model",
12796 "messages": [
12797 {"role": "user", "content": "Call weather."},
12798 {
12799 "role": "assistant",
12800 "content": null,
12801 "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
12802 },
12803 {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
12804 ],
12805 "functions": [{
12806 "name": "weather",
12807 "parameters": {
12808 "type": "object",
12809 "properties": {"city": {"type": "string"}},
12810 "required": ["city"]
12811 }
12812 }],
12813 "function_call": "auto"
12814 }))
12815 .expect("legacy function request parses");
12816
12817 validate_chat_request(&request).expect("legacy function request validates");
12818 let internal = convert_chat_request(&request).expect("convert");
12819 assert!(
12820 internal
12821 .prompt
12822 .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
12823 "legacy function role should be preserved in fallback template: {}",
12824 internal.prompt
12825 );
12826 assert_eq!(
12827 internal.metadata["openai_legacy_functions"][0]["name"],
12828 "weather"
12829 );
12830 assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
12831 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12832 panic!("expected structured chat api_request");
12833 };
12834 assert_eq!(api.messages.len(), 3);
12835 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
12836 assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
12837 assert_eq!(
12838 api.messages[1]
12839 .function_call
12840 .as_ref()
12841 .map(|call| call.name.as_str()),
12842 Some("weather")
12843 );
12844 assert_eq!(api.legacy_functions[0].name, "weather");
12845 assert_eq!(
12846 api.legacy_function_call,
12847 Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
12848 );
12849 }
12850
12851 #[test]
12852 fn specific_legacy_function_call_parses_into_structured_api_request() {
12853 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12854 "model": "mystery-model",
12855 "messages": [{"role": "user", "content": "Use the selected function."}],
12856 "functions": [
12857 {"name": "weather", "parameters": {"type": "object"}},
12858 {"name": "calendar", "parameters": {"type": "object"}}
12859 ],
12860 "function_call": {"name": "weather"}
12861 }))
12862 .expect("specific function_call request parses");
12863
12864 validate_chat_request(&request).expect("specific function_call validates");
12865 let internal = convert_chat_request(&request).expect("convert");
12866 assert!(internal.prompt.contains("\"function_call\":{"));
12867 assert!(internal.prompt.contains("\"name\":\"weather\""));
12868 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12869 panic!("expected structured chat api_request");
12870 };
12871 assert_eq!(
12872 api.legacy_function_call,
12873 Some(ferrum_types::ApiFunctionCallChoice::Function {
12874 name: "weather".to_string(),
12875 })
12876 );
12877
12878 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
12879 "model": "mystery-model",
12880 "messages": [{"role": "user", "content": "Use the selected function."}],
12881 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
12882 "function_call": {"name": "calendar"}
12883 }))
12884 .expect("invalid specific function_call request parses");
12885 let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
12886 match err {
12887 ServerError::InvalidRequest { param, .. } => {
12888 assert_eq!(param.as_deref(), Some("function_call"));
12889 }
12890 other => panic!("expected invalid_request_error for function_call, got {other:?}"),
12891 }
12892 }
12893
12894 #[test]
12895 fn stream_text_delta_handles_unicode_boundaries() {
12896 let mut sent_len = 0usize;
12897 assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
12898 assert_eq!(sent_len, "你好".len());
12899 assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
12900 assert_eq!(sent_len, "你好世界".len());
12901 }
12902
12903 #[test]
12904 fn stream_text_delta_recovers_from_non_boundary_offset() {
12905 let mut sent_len = 1usize;
12906 assert_eq!(stream_text_delta("你好", &mut sent_len), "");
12907 assert_eq!(sent_len, "你好".len());
12908 }
12909
12910 #[test]
12911 fn assistant_tool_call_serializes_openai_shape() {
12912 let message = ChatMessage {
12913 role: MessageRole::Assistant,
12914 content: String::new(),
12915 reasoning: None,
12916 name: None,
12917 tool_calls: Some(vec![ChatToolCall {
12918 index: None,
12919 id: "call_1".to_string(),
12920 tool_type: "function".to_string(),
12921 function: ChatFunctionCall {
12922 name: "weather".to_string(),
12923 arguments: "{\"city\":\"Paris\"}".to_string(),
12924 },
12925 }]),
12926 tool_call_id: None,
12927 function_call: None,
12928 };
12929 let value = serde_json::to_value(message).expect("serialize");
12930 assert_eq!(value["role"], "assistant");
12931 assert_eq!(value["tool_calls"][0]["type"], "function");
12932 assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
12933 }
12934
12935 #[test]
12936 fn unsupported_multimodal_content_is_not_silently_dropped() {
12937 let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
12938 "model": "stub-model",
12939 "messages": [{
12940 "role": "user",
12941 "content": [
12942 {"type": "text", "text": "describe this"},
12943 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
12944 ]
12945 }]
12946 }))
12947 .expect_err("unsupported content part should fail parsing");
12948 assert!(
12949 err.to_string()
12950 .contains("unsupported message content part type"),
12951 "unexpected error: {err}"
12952 );
12953 }
12954
12955 #[tokio::test]
12956 async fn completions_endpoint_uses_stub_engine() {
12957 let request = CompletionsRequest {
12958 model: "stub-model".to_string(),
12959 prompt: CompletionPrompt::Text("complete me".to_string()),
12960 max_tokens: Some(8),
12961 temperature: Some(0.0),
12962 top_p: None,
12963 n: None,
12964 stream: None,
12965 stop: None,
12966 logprobs: None,
12967 logit_bias: None,
12968 };
12969 let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
12970 .await
12971 .expect("completion response");
12972 assert_eq!(response.status(), AxumStatusCode::OK);
12973 let body = response_json(response).await;
12974 assert_eq!(body["object"], "text_completion");
12975 assert_eq!(body["choices"][0]["text"], "done");
12976 assert_eq!(body["usage"]["prompt_tokens"], 7);
12977 assert_eq!(body["usage"]["completion_tokens"], 2);
12978 }
12979
12980 #[tokio::test]
12981 async fn route_completions_rejects_non_string_prompt_with_field_param() {
12982 for prompt in [
12983 json!(["a", "b"]),
12984 json!({"text": "complete me"}),
12985 Value::Null,
12986 ] {
12987 let response = post_json(
12988 router_with_stub("unused"),
12989 "/v1/completions",
12990 json!({
12991 "model": "stub-model",
12992 "prompt": prompt
12993 }),
12994 )
12995 .await;
12996 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12997 let body = response_json(response).await;
12998 assert_eq!(body["error"]["type"], "invalid_request_error");
12999 assert_eq!(body["error"]["param"], "prompt");
13000 }
13001
13002 let response = post_json(
13003 router_with_stub("unused"),
13004 "/v1/completions",
13005 json!({"model": "stub-model"}),
13006 )
13007 .await;
13008 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13009 let body = response_json(response).await;
13010 assert_eq!(body["error"]["type"], "invalid_request_error");
13011 assert_eq!(body["error"]["param"], "prompt");
13012 }
13013
13014 #[tokio::test]
13015 async fn stream_options_without_stream_is_invalid() {
13016 let request = chat_request(json!({"stream_options": {"include_usage": true}}));
13017 let err = chat_completions_handler(
13018 State(state_with_stub("unused")),
13019 HeaderMap::new(),
13020 Ok(Json(request)),
13021 )
13022 .await
13023 .expect_err("stream_options without stream should reject");
13024 let (status, body) = error_json(err).await;
13025 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13026 assert_eq!(body["error"]["param"], "stream_options");
13027 assert_eq!(body["error"]["type"], "invalid_request_error");
13028 }
13029
13030 #[tokio::test]
13031 async fn unknown_stream_option_is_rejected_instead_of_ignored() {
13032 let response = post_json(
13033 router_with_stub("unused"),
13034 "/v1/chat/completions",
13035 json!({
13036 "model": "stub-model",
13037 "messages": [{"role": "user", "content": "hello"}],
13038 "stream": true,
13039 "stream_options": {"continuous_usage_stats": true}
13040 }),
13041 )
13042 .await;
13043
13044 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13045 let body = response_json(response).await;
13046 assert_eq!(body["error"]["type"], "invalid_request_error");
13047 assert!(
13048 body["error"]["message"]
13049 .as_str()
13050 .unwrap_or_default()
13051 .contains("invalid chat completions request"),
13052 "body: {body}"
13053 );
13054 }
13055
13056 #[tokio::test]
13057 async fn json_object_rejects_markdown_fence_instead_of_repairing() {
13058 let request = chat_request(json!({
13059 "response_format": {"type": "json_object"}
13060 }));
13061 let err = chat_completions_handler(
13062 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
13063 HeaderMap::new(),
13064 Ok(Json(request)),
13065 )
13066 .await
13067 .expect_err("fenced json_object must fail");
13068 let (status, body) = error_json(err).await;
13069 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13070 assert_eq!(body["error"]["type"], "internal_server_error");
13071 assert!(body["error"]["message"]
13072 .as_str()
13073 .unwrap_or_default()
13074 .contains("response_format.json_object: invalid JSON"));
13075 }
13076
13077 #[tokio::test]
13078 async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
13079 let response = post_json(
13080 router_with_stub_stream_chunks(&[
13081 "<think>\n好的,我需要输出 JSON。",
13082 "\n</think>\n\n",
13083 "{\"name\":\"李四\",\"age\":30}",
13084 ]),
13085 "/v1/chat/completions",
13086 json!({
13087 "model": "stub-model",
13088 "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
13089 "stream": true,
13090 "response_format": {"type": "json_object"}
13091 }),
13092 )
13093 .await;
13094 assert_eq!(response.status(), AxumStatusCode::OK);
13095 let body = response_text(response).await;
13096 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
13097 assert!(
13098 body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
13099 "stream should emit clean JSON content: {body}"
13100 );
13101 assert!(
13102 body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
13103 "stream should keep thinking in reasoning field: {body}"
13104 );
13105 assert!(
13106 !body.contains(r#""content":"<think"#)
13107 && !body.contains(r#""content":"好的"#)
13108 && !body.contains(r#""content":"我需要"#),
13109 "thinking text must not leak as streamed content: {body}"
13110 );
13111 }
13112
13113 fn prompt_opened_literal_json_template() -> ModelChatTemplate {
13114 let template = ModelChatTemplate::new(
13115 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>{% endif %}",
13116 "prompt-opened-text-test",
13117 );
13118 assert_eq!(template.output_protocol, ModelOutputProtocol::Text);
13119 assert_eq!(
13120 template.reasoning_protocol,
13121 ModelReasoningProtocol::PromptOpened
13122 );
13123 let request = chat_request(json!({"response_format": {"type": "json_object"}}));
13124 let internal =
13125 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13126 .expect("convert prompt-opened Text request");
13127 assert!(internal.prompt.ends_with("<think>"));
13128 template
13129 }
13130
13131 #[tokio::test]
13132 async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sync() {
13133 let response = post_json(
13134 router_with_stub_and_template(
13135 "reason</think>\n{\"text\":\"<think>literal</think>\"}",
13136 prompt_opened_literal_json_template(),
13137 ),
13138 "/v1/chat/completions",
13139 json!({
13140 "model": "stub-model",
13141 "messages": [{"role": "user", "content": "Return a JSON object."}],
13142 "response_format": {"type": "json_object"}
13143 }),
13144 )
13145 .await;
13146 let status = response.status();
13147 let body = response_json(response).await;
13148 assert_eq!(status, AxumStatusCode::OK, "{body}");
13149 assert!(body.get("error").is_none(), "{body}");
13150 let message = &body["choices"][0]["message"];
13151 assert_eq!(message["content"], r#"{"text":"<think>literal</think>"}"#);
13152 assert_eq!(message["reasoning"], "reason");
13153 assert_eq!(body["choices"][0]["finish_reason"], "stop");
13154 }
13155
13156 #[tokio::test]
13157 async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sse() {
13158 for chunks in [
13159 vec!["reason</think>\n{\"text\":\"<think>literal</think>\"}"],
13160 vec![
13161 "reason</thi",
13162 "nk>\n{\"text\":\"<thi",
13163 "nk>literal</thi",
13164 "nk>\"}",
13165 ],
13166 ] {
13167 let router = AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(&chunks)))
13168 .with_prompt_template(Some(prompt_opened_literal_json_template()))
13169 .build_router();
13170 let response = post_json(
13171 router,
13172 "/v1/chat/completions",
13173 json!({
13174 "model": "stub-model",
13175 "messages": [{"role": "user", "content": "Return a JSON object."}],
13176 "stream": true,
13177 "stream_options": {"include_usage": true},
13178 "response_format": {"type": "json_object"}
13179 }),
13180 )
13181 .await;
13182 let status = response.status();
13183 let body = response_text(response).await;
13184 assert_eq!(status, AxumStatusCode::OK, "{body}");
13185 let normalized = body.replace("\r\n", "\n");
13186 assert_eq!(normalized.matches("data: [DONE]").count(), 1, "{body}");
13187 assert!(normalized.ends_with("data: [DONE]\n\n"), "{body}");
13188 let events = responses_sse_json_events(&body);
13189 assert!(
13190 events.iter().all(|event| event.get("error").is_none()),
13191 "{body}"
13192 );
13193 let content: String = events
13194 .iter()
13195 .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
13196 .collect();
13197 let reasoning: String = events
13198 .iter()
13199 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
13200 .collect();
13201 assert_eq!(content, r#"{"text":"<think>literal</think>"}"#);
13202 assert_eq!(reasoning, "reason");
13203 assert_eq!(
13204 serde_json::from_str::<Value>(&content).expect("intact JSON body"),
13205 json!({"text": "<think>literal</think>"})
13206 );
13207 let terminals: Vec<_> = events
13208 .iter()
13209 .enumerate()
13210 .filter(|(_, event)| !event["choices"][0]["finish_reason"].is_null())
13211 .collect();
13212 assert_eq!(terminals.len(), 1, "{body}");
13213 let (terminal_index, terminal) = terminals[0];
13214 assert_eq!(terminal["choices"][0]["finish_reason"], "stop");
13215 for event in &events[terminal_index..] {
13216 for field in ["content", "reasoning", "reasoning_content"] {
13217 assert!(
13218 event["choices"][0]["delta"][field]
13219 .as_str()
13220 .unwrap_or_default()
13221 .is_empty(),
13222 "payload after terminal: {event}"
13223 );
13224 }
13225 }
13226 let usages: Vec<_> = events
13227 .iter()
13228 .enumerate()
13229 .filter(|(_, event)| !event["usage"].is_null())
13230 .collect();
13231 assert_eq!(usages.len(), 1, "{body}");
13232 let (usage_index, usage) = usages[0];
13233 assert!(terminal_index < usage_index, "{body}");
13234 assert_eq!(usage_index, events.len() - 1, "usage must be last: {body}");
13235 assert_eq!(usage["choices"], json!([]));
13236 }
13237 }
13238
13239 #[tokio::test]
13240 async fn json_object_rejects_non_json_model_output() {
13241 let request = chat_request(json!({
13242 "response_format": {"type": "json_object"}
13243 }));
13244 let err = chat_completions_handler(
13245 State(state_with_stub("not json")),
13246 HeaderMap::new(),
13247 Ok(Json(request)),
13248 )
13249 .await
13250 .expect_err("invalid json_object must fail");
13251 let (status, body) = error_json(err).await;
13252 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13253 assert_eq!(body["error"]["type"], "internal_server_error");
13254 assert!(body["error"]["message"]
13255 .as_str()
13256 .unwrap_or_default()
13257 .contains("response_format.json_object"));
13258 }
13259
13260 #[test]
13261 fn one_of_strict_json_schema_reaches_hard_decoder() {
13262 let request = chat_request(json!({
13263 "response_format": {
13264 "type": "json_schema",
13265 "json_schema": {
13266 "name": "unsupported",
13267 "strict": true,
13268 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13269 }
13270 }
13271 }));
13272 validate_chat_request(&request).expect("oneOf strict schema should validate");
13273 let internal = convert_chat_request(&request).expect("convert oneOf strict schema");
13274 let ferrum_types::ResponseFormat::JsonSchema(schema) =
13275 internal.sampling_params.response_format
13276 else {
13277 panic!("strict schema did not reach hard decoder");
13278 };
13279 assert_eq!(
13280 serde_json::from_str::<serde_json::Value>(&schema).unwrap()["oneOf"],
13281 json!([{"type": "string"}, {"type": "integer"}])
13282 );
13283 let schema = serde_json::from_str::<serde_json::Value>(&schema).unwrap();
13284 validate_json_text_against_schema(&schema, r#""answer""#)
13285 .expect("oneOf string branch should pass final validation");
13286 validate_json_text_against_schema(&schema, "7")
13287 .expect("oneOf integer branch should pass final validation");
13288 assert!(validate_json_text_against_schema(&schema, "true").is_err());
13289 }
13290
13291 #[tokio::test]
13292 async fn missing_json_schema_schema_rejects_with_field_param() {
13293 let request = chat_request(json!({
13294 "response_format": {
13295 "type": "json_schema",
13296 "json_schema": {
13297 "name": "missing_schema",
13298 "strict": true
13299 }
13300 }
13301 }));
13302 let err = chat_completions_handler(
13303 State(state_with_stub("unused")),
13304 HeaderMap::new(),
13305 Ok(Json(request)),
13306 )
13307 .await
13308 .expect_err("missing strict schema should reject");
13309 let (status, body) = error_json(err).await;
13310 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13311 assert_eq!(body["error"]["param"], "response_format.json_schema");
13312 assert_eq!(body["error"]["type"], "invalid_request_error");
13313 assert!(body["error"]["message"]
13314 .as_str()
13315 .unwrap()
13316 .contains("schema is required"));
13317 }
13318
13319 #[test]
13320 fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
13321 let request = chat_request(json!({
13322 "response_format": {
13323 "type": "json_schema",
13324 "json_schema": {
13325 "name": "best_effort",
13326 "strict": false,
13327 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13328 }
13329 }
13330 }));
13331
13332 validate_chat_request(&request).expect("non-strict schema should not boundary reject");
13333 let internal = convert_chat_request(&request).expect("convert non-strict schema");
13334 assert!(
13335 internal
13336 .prompt
13337 .contains("response_format requires a single valid JSON value"),
13338 "response_format instruction should reach the model prompt: {}",
13339 internal.prompt
13340 );
13341 assert!(
13342 internal.prompt.contains("\"oneOf\""),
13343 "schema should reach the model prompt: {}",
13344 internal.prompt
13345 );
13346 assert_eq!(
13347 internal.sampling_params.response_format,
13348 ferrum_types::ResponseFormat::Text,
13349 "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
13350 );
13351 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13352 panic!("expected structured chat api_request");
13353 };
13354 assert_eq!(
13355 api.response_format
13356 .as_ref()
13357 .and_then(|format| format.json_schema.as_ref())
13358 .and_then(|schema| schema.strict),
13359 Some(false)
13360 );
13361 }
13362
13363 #[test]
13364 fn json_object_response_format_instruction_reaches_model_prompt() {
13365 let request = chat_request(json!({
13366 "response_format": {"type": "json_object"}
13367 }));
13368
13369 let internal = convert_chat_request(&request).expect("convert json_object");
13370 assert!(
13371 internal
13372 .prompt
13373 .contains("response_format requires a single valid JSON object"),
13374 "response_format instruction should reach the model prompt: {}",
13375 internal.prompt
13376 );
13377 assert!(
13378 internal.prompt.contains("Output only JSON"),
13379 "JSON-only instruction should reach the model prompt: {}",
13380 internal.prompt
13381 );
13382 assert_eq!(
13383 internal.sampling_params.response_format,
13384 ferrum_types::ResponseFormat::JsonObject,
13385 "json_object must reach the tokenizer-aware hard decoder"
13386 );
13387 assert_eq!(
13388 internal.sampling_params.structured_output_start,
13389 StructuredOutputStart::Immediate
13390 );
13391 }
13392
13393 fn harmony_json_template() -> ModelChatTemplate {
13394 let mut template = ModelChatTemplate::new(
13395 "{% for message in messages %}<|start|>{{ message.role }}<|message|>{{ message.content }}<|end|>{% endfor %}{% if add_generation_prompt %}<|start|>assistant{% endif %}",
13396 "harmony-structured-template",
13397 );
13398 template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
13399 template
13400 }
13401
13402 #[test]
13403 fn harmony_structured_format_activates_at_final_payload() {
13404 let template = harmony_json_template();
13405 for (response_format, constrained) in [
13406 (json!({"type": "json_object"}), true),
13407 (
13408 json!({
13409 "type": "json_schema",
13410 "json_schema": {
13411 "name": "answer",
13412 "strict": true,
13413 "schema": {
13414 "type": "object",
13415 "properties": {"answer": {"type": "integer"}},
13416 "required": ["answer"],
13417 "additionalProperties": false
13418 }
13419 }
13420 }),
13421 true,
13422 ),
13423 (
13424 json!({
13425 "type": "json_schema",
13426 "json_schema": {
13427 "name": "best_effort",
13428 "strict": false,
13429 "schema": {"type": "object"}
13430 }
13431 }),
13432 false,
13433 ),
13434 (json!({"type": "text"}), false),
13435 ] {
13436 let request = chat_request(json!({"response_format": response_format}));
13437 let internal =
13438 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13439 .unwrap();
13440 assert_eq!(
13441 internal.sampling_params.structured_output_start,
13442 if constrained {
13443 StructuredOutputStart::HarmonyFinal
13444 } else {
13445 StructuredOutputStart::Immediate
13446 }
13447 );
13448 assert_eq!(
13449 internal.sampling_params.response_completion_boundary,
13450 ResponseCompletionBoundary::Immediate,
13451 "Harmony framing must not be gated on a Text reasoning delimiter"
13452 );
13453 internal.sampling_params.validate().unwrap();
13454 }
13455 }
13456
13457 fn harmony_json_request(stream: bool) -> Value {
13458 json!({
13459 "model": "stub-model",
13460 "messages": [{"role": "user", "content": "Return an answer object."}],
13461 "stream": stream,
13462 "response_format": {
13463 "type": "json_schema",
13464 "json_schema": {
13465 "name": "answer",
13466 "strict": true,
13467 "schema": {
13468 "type": "object",
13469 "properties": {"answer": {"type": "integer"}},
13470 "required": ["answer"],
13471 "additionalProperties": false
13472 }
13473 }
13474 }
13475 })
13476 }
13477
13478 #[tokio::test]
13479 async fn harmony_strict_json_routes_validate_final_payload_in_sync_and_sse() {
13480 for (chunks, finish_reason, reasoning) in [
13481 (
13482 vec![
13483 "<|channel|>fi",
13484 "nal<|message|>{\"answer\":",
13485 "42}<|return|>",
13486 ],
13487 FinishReason::EOS,
13488 "",
13489 ),
13490 (
13491 vec![
13492 "<|channel|>analysis<|message|>Compute.",
13493 "<|end|><|start|>assistant<|channel|>fi",
13494 "nal<|message|>{\"answer\":42}<|return|>",
13495 ],
13496 FinishReason::EOS,
13497 "Compute.",
13498 ),
13499 (
13500 vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
13501 FinishReason::Length,
13502 "",
13503 ),
13504 (
13505 vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
13506 FinishReason::Stop,
13507 "",
13508 ),
13509 ] {
13510 for stream in [false, true] {
13511 let engine = StubLlm {
13512 finish_reason,
13513 ..StubLlm::with_stream_chunks(&chunks)
13514 };
13515 let router = AxumServer::from_llm(Arc::new(engine))
13516 .with_prompt_template(Some(harmony_json_template()))
13517 .build_router();
13518 let mut request = harmony_json_request(stream);
13519 if finish_reason == FinishReason::Stop {
13520 request["stop"] = json!(["<|return|>"]);
13523 }
13524 let response = post_json(router, "/v1/chat/completions", request).await;
13525 assert_eq!(response.status(), AxumStatusCode::OK);
13526 if stream {
13527 let body = response_text(response).await;
13528 assert!(body.contains("data: [DONE]"));
13529 let events = responses_sse_json_events(&body);
13530 assert!(events.iter().all(|event| event.get("error").is_none()));
13531 let content: String = events
13532 .iter()
13533 .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
13534 .collect();
13535 let actual_reasoning: String = events
13536 .iter()
13537 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
13538 .collect();
13539 assert_eq!(
13540 serde_json::from_str::<Value>(&content).unwrap(),
13541 json!({"answer": 42})
13542 );
13543 assert_eq!(actual_reasoning, reasoning);
13544 } else {
13545 let body = response_json(response).await;
13546 let message = &body["choices"][0]["message"];
13547 assert_eq!(message["content"], "{\"answer\":42}");
13548 assert_eq!(message["reasoning"].as_str().unwrap_or(""), reasoning);
13549 }
13550 }
13551 }
13552 }
13553
13554 #[tokio::test]
13555 async fn harmony_strict_json_routes_reject_bad_framing_and_payload_without_sse_leaks() {
13556 for output in [
13557 "{\"answer\":42}",
13558 "<|channel|>final<|message|>{\"answer\":42}",
13559 "<|channel|>final<|message|>{\"answer\":42}<|call|>",
13560 "<|channel|>analysis<|message|>Compute.<|end|>\
13561 <|start|>assistant<|channel|>final<|message|>{\"answer\":\"wrong\"}<|return|>",
13562 ] {
13563 for stream in [false, true] {
13564 let response = post_json(
13565 router_with_stub_and_template(output, harmony_json_template()),
13566 "/v1/chat/completions",
13567 harmony_json_request(stream),
13568 )
13569 .await;
13570 if stream {
13571 assert_eq!(response.status(), AxumStatusCode::OK);
13572 let body = response_text(response).await;
13573 assert!(body.contains("data: [DONE]"));
13574 let events = responses_sse_json_events(&body);
13575 assert!(events.iter().any(|event| event.get("error").is_some()));
13576 for event in events {
13577 for field in ["content", "reasoning"] {
13578 assert!(event["choices"][0]["delta"][field]
13579 .as_str()
13580 .unwrap_or("")
13581 .is_empty());
13582 }
13583 }
13584 } else {
13585 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
13586 let body = response_json(response).await;
13587 assert_eq!(body["error"]["type"], "internal_server_error");
13588 assert!(body.get("choices").is_none());
13589 }
13590 }
13591 }
13592 }
13593
13594 #[test]
13595 fn json_object_thinking_template_activates_after_typed_end_delimiter() {
13596 let request = chat_request(json!({
13597 "response_format": {"type": "json_object"}
13598 }));
13599 let template = ModelChatTemplate::new(
13600 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
13601 "thinking-test-template",
13602 );
13603
13604 assert_eq!(
13605 template.reasoning_protocol,
13606 ModelReasoningProtocol::PromptOpened
13607 );
13608 let internal =
13609 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13610 .expect("convert thinking json_object");
13611
13612 assert!(internal.prompt.ends_with("<assistant><think>\n"));
13613 assert!(internal
13614 .prompt
13615 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
13616 assert!(
13617 !internal.prompt.contains(THINK_END_TAG),
13618 "the instruction must not echo the typed end delimiter: {}",
13619 internal.prompt
13620 );
13621 assert_eq!(
13622 internal.sampling_params.structured_output_start,
13623 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
13624 );
13625 assert_eq!(
13626 internal.sampling_params.response_completion_boundary,
13627 ResponseCompletionBoundary::AfterDelimiterAndPayload {
13628 delimiter: THINK_END_TAG.to_string(),
13629 alternate_envelope: None,
13630 }
13631 );
13632 }
13633
13634 #[test]
13635 fn json_object_model_generated_thinking_activates_after_typed_end_delimiter() {
13636 let request = chat_request(json!({
13637 "response_format": {"type": "json_object"},
13638 "chat_template_kwargs": {"enable_thinking": true}
13639 }));
13640 let template = ModelChatTemplate::new(
13641 "{% 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 %}",
13642 "qwen3-model-generated-thinking-template",
13643 );
13644
13645 let internal =
13646 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13647 .expect("convert model-generated thinking json_object");
13648
13649 assert!(!has_unclosed_thinking_block(&internal.prompt));
13650 assert!(internal.prompt.ends_with("<assistant>"));
13651 assert!(internal
13652 .prompt
13653 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
13654 assert!(
13655 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
13656 "the instruction must not teach the model the typed reasoning delimiter: {}",
13657 internal.prompt
13658 );
13659 assert_eq!(
13660 internal.sampling_params.structured_output_start,
13661 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
13662 );
13663 assert_eq!(
13664 internal.sampling_params.response_completion_boundary,
13665 ResponseCompletionBoundary::AfterDelimiterAndPayload {
13666 delimiter: THINK_END_TAG.to_string(),
13667 alternate_envelope: None,
13668 }
13669 );
13670 }
13671
13672 #[test]
13673 fn strict_schema_model_generated_thinking_does_not_echo_typed_delimiter() {
13674 let request = chat_request(json!({
13675 "response_format": {
13676 "type": "json_schema",
13677 "json_schema": {
13678 "name": "reasoning_result",
13679 "strict": true,
13680 "schema": {
13681 "type": "object",
13682 "properties": {
13683 "result": {"type": "string", "const": "G00-c21-schema-OK"}
13684 },
13685 "required": ["result"],
13686 "additionalProperties": false
13687 }
13688 }
13689 },
13690 "chat_template_kwargs": {"enable_thinking": true}
13691 }));
13692 let template = ModelChatTemplate::new(
13693 "{% 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 %}",
13694 "qwen3-model-generated-thinking-template",
13695 );
13696
13697 let internal =
13698 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13699 .expect("convert model-generated thinking strict schema");
13700
13701 assert!(!has_unclosed_thinking_block(&internal.prompt));
13702 assert!(internal.prompt.ends_with("<assistant>"));
13703 assert!(internal.prompt.contains("G00-c21-schema-OK"));
13704 assert!(
13705 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
13706 "the instruction must not teach the model the typed reasoning delimiter: {}",
13707 internal.prompt
13708 );
13709 assert_eq!(
13710 internal.sampling_params.structured_output_start,
13711 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
13712 );
13713 assert_eq!(
13714 internal.sampling_params.response_completion_boundary,
13715 ResponseCompletionBoundary::AfterDelimiterAndPayload {
13716 delimiter: THINK_END_TAG.to_string(),
13717 alternate_envelope: None,
13718 }
13719 );
13720 }
13721
13722 #[test]
13723 fn json_object_model_generated_thinking_hard_off_starts_immediately() {
13724 let request = chat_request(json!({
13725 "response_format": {"type": "json_object"},
13726 "chat_template_kwargs": {"enable_thinking": false}
13727 }));
13728 let template = ModelChatTemplate::new(
13729 "{% 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 %}",
13730 "qwen3-model-generated-thinking-template",
13731 );
13732
13733 let internal =
13734 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13735 .expect("convert disabled model-generated thinking json_object");
13736
13737 assert_eq!(
13738 internal.sampling_params.structured_output_start,
13739 StructuredOutputStart::Immediate
13740 );
13741 assert_eq!(
13742 internal.sampling_params.response_completion_boundary,
13743 ResponseCompletionBoundary::Immediate
13744 );
13745 assert!(internal.prompt.contains("no chain-of-thought"));
13746 }
13747
13748 #[test]
13749 fn response_completion_contract_is_set_on_text_thinking_template() {
13750 let request = chat_request(json!({}));
13751 let template = ModelChatTemplate::new(
13752 "{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
13753 "thinking-test-template",
13754 );
13755
13756 let internal =
13757 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13758 .expect("convert thinking text request");
13759
13760 assert_eq!(
13761 internal.sampling_params.structured_output_start,
13762 StructuredOutputStart::Immediate
13763 );
13764 assert_eq!(
13765 internal.sampling_params.response_completion_boundary,
13766 ResponseCompletionBoundary::AfterDelimiterAndPayload {
13767 delimiter: THINK_END_TAG.to_string(),
13768 alternate_envelope: None,
13769 }
13770 );
13771 }
13772
13773 #[test]
13774 fn thinking_tool_request_compiles_typed_envelope_into_completion_contract() {
13775 let request = chat_request(json!({
13776 "tools": [{
13777 "type": "function",
13778 "function": {
13779 "name": "weather",
13780 "parameters": {
13781 "type": "object",
13782 "properties": {"city": {"type": "string"}},
13783 "required": ["city"]
13784 }
13785 }
13786 }]
13787 }));
13788 let template = ModelChatTemplate::new(
13789 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
13790 "thinking-tool-template",
13791 );
13792
13793 let internal =
13794 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13795 .expect("convert thinking tool request");
13796
13797 assert_eq!(
13798 internal.sampling_params.response_completion_boundary,
13799 ResponseCompletionBoundary::AfterDelimiterAndPayload {
13800 delimiter: THINK_END_TAG.to_string(),
13801 alternate_envelope: Some(ferrum_types::ResponseCompletionEnvelope {
13802 open_token_text: "<tool_call>".to_string(),
13803 close_token_text: "</tool_call>".to_string(),
13804 max_envelopes: 32,
13805 }),
13806 }
13807 );
13808 }
13809
13810 #[test]
13811 fn strict_json_schema_response_format_uses_guided_sampling_mode() {
13812 let request = chat_request(json!({
13813 "response_format": {
13814 "type": "json_schema",
13815 "json_schema": {
13816 "name": "answer",
13817 "strict": true,
13818 "schema": {
13819 "type": "object",
13820 "properties": {"answer": {"type": "string"}},
13821 "required": ["answer"]
13822 }
13823 }
13824 }
13825 }));
13826
13827 let internal = convert_chat_request(&request).expect("convert strict json_schema");
13828 assert!(
13829 internal
13830 .prompt
13831 .contains("response_format requires a single valid JSON value"),
13832 "response_format instruction should reach the model prompt: {}",
13833 internal.prompt
13834 );
13835 let ferrum_types::ResponseFormat::JsonSchema(schema) =
13836 internal.sampling_params.response_format
13837 else {
13838 panic!(
13839 "strict json_schema must reach guided decoding, got {:?}",
13840 internal.sampling_params.response_format
13841 );
13842 };
13843 let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
13844 assert_eq!(schema["type"], "object");
13845 assert_eq!(schema["properties"]["answer"]["type"], "string");
13846 assert_eq!(schema["required"], json!(["answer"]));
13847 }
13848
13849 #[tokio::test]
13850 async fn strict_json_schema_validates_non_streaming_response() {
13851 let request = chat_request(json!({
13852 "response_format": {
13853 "type": "json_schema",
13854 "json_schema": {
13855 "name": "answer",
13856 "strict": true,
13857 "schema": {
13858 "type": "object",
13859 "properties": {"answer": {"type": "string"}},
13860 "required": ["answer"]
13861 }
13862 }
13863 }
13864 }));
13865 let response = chat_completions_handler(
13866 State(state_with_stub("{\"answer\":\"yes\"}")),
13867 HeaderMap::new(),
13868 Ok(Json(request)),
13869 )
13870 .await
13871 .expect("strict response");
13872 assert_eq!(response.status(), AxumStatusCode::OK);
13873 let body = response_json(response).await;
13874 assert_eq!(
13875 body["choices"][0]["message"]["content"],
13876 "{\"answer\":\"yes\"}"
13877 );
13878 }
13879
13880 #[tokio::test]
13881 async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
13882 let request = chat_request(json!({
13883 "response_format": {
13884 "type": "json_schema",
13885 "json_schema": {
13886 "name": "answer",
13887 "strict": true,
13888 "schema": {
13889 "type": "object",
13890 "properties": {"answer": {"type": "string"}},
13891 "required": ["answer"]
13892 }
13893 }
13894 }
13895 }));
13896 let response = chat_completions_handler(
13897 State(state_with_stub(
13898 "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
13899 )),
13900 HeaderMap::new(),
13901 Ok(Json(request)),
13902 )
13903 .await
13904 .expect("strict response with reasoning");
13905 assert_eq!(response.status(), AxumStatusCode::OK);
13906 let body = response_json(response).await;
13907 assert_eq!(
13908 body["choices"][0]["message"]["content"],
13909 "{\"answer\":\"yes\"}"
13910 );
13911 assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
13912 }
13913
13914 #[tokio::test]
13915 async fn strict_json_schema_validates_streaming_final_response() {
13916 let response = post_json(
13917 router_with_stub("{\"answer\":\"yes\"}"),
13918 "/v1/chat/completions",
13919 json!({
13920 "model": "stub-model",
13921 "messages": [{"role": "user", "content": "Return an answer object."}],
13922 "stream": true,
13923 "response_format": {
13924 "type": "json_schema",
13925 "json_schema": {
13926 "name": "answer",
13927 "strict": true,
13928 "schema": {
13929 "type": "object",
13930 "properties": {"answer": {"type": "string"}},
13931 "required": ["answer"]
13932 }
13933 }
13934 }
13935 }),
13936 )
13937 .await;
13938 assert_eq!(response.status(), AxumStatusCode::OK);
13939 let body = response_text(response).await;
13940 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
13941 assert!(
13942 body.contains("\\\"answer\\\":\\\"yes\\\""),
13943 "strict streaming content missing: {body}"
13944 );
13945 assert!(
13946 !body.contains("\"error\""),
13947 "valid strict streaming response should not emit error: {body}"
13948 );
13949 }
13950
13951 #[tokio::test]
13952 async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
13953 let response = post_json(
13954 router_with_stub_stream_chunks(&[
13955 "<think>\nreasoning",
13956 "\n</think>\n\n",
13957 "{\"answer\":\"yes\"}",
13958 ]),
13959 "/v1/chat/completions",
13960 json!({
13961 "model": "stub-model",
13962 "messages": [{"role": "user", "content": "Return an answer object."}],
13963 "stream": true,
13964 "response_format": {
13965 "type": "json_schema",
13966 "json_schema": {
13967 "name": "answer",
13968 "strict": true,
13969 "schema": {
13970 "type": "object",
13971 "properties": {"answer": {"type": "string"}},
13972 "required": ["answer"]
13973 }
13974 }
13975 }
13976 }),
13977 )
13978 .await;
13979 assert_eq!(response.status(), AxumStatusCode::OK);
13980 let body = response_text(response).await;
13981 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
13982 assert!(
13983 body.contains("\\\"answer\\\":\\\"yes\\\""),
13984 "strict streaming content missing: {body}"
13985 );
13986 assert!(
13987 body.contains(r#""reasoning":"\nreasoning\n""#),
13988 "strict streaming should keep reasoning separate: {body}"
13989 );
13990 assert!(
13991 !body.contains("\"error\""),
13992 "valid strict streaming response should not emit error: {body}"
13993 );
13994 }
13995
13996 #[tokio::test]
13997 async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
13998 let response = post_json(
13999 router_with_stub("not json"),
14000 "/v1/chat/completions",
14001 json!({
14002 "model": "stub-model",
14003 "messages": [{"role": "user", "content": "Return an answer object."}],
14004 "stream": true,
14005 "response_format": {
14006 "type": "json_schema",
14007 "json_schema": {
14008 "name": "answer",
14009 "strict": true,
14010 "schema": {
14011 "type": "object",
14012 "properties": {"answer": {"type": "string"}},
14013 "required": ["answer"]
14014 }
14015 }
14016 }
14017 }),
14018 )
14019 .await;
14020 assert_eq!(response.status(), AxumStatusCode::OK);
14021 let body = response_text(response).await;
14022 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14023 assert!(
14024 body.contains("\"type\":\"internal_server_error\""),
14025 "strict streaming validation failure should emit OpenAI error: {body}"
14026 );
14027 assert!(
14028 body.contains("\"param\":\"response_format.json_schema\""),
14029 "strict streaming validation error should identify schema param: {body}"
14030 );
14031 assert!(
14032 body.contains("invalid JSON"),
14033 "strict streaming validation should report invalid JSON: {body}"
14034 );
14035 assert!(
14036 !body.contains("not json"),
14037 "strict streaming must not emit invalid partial deltas before validation failure: {body}"
14038 );
14039 }
14040
14041 #[tokio::test]
14042 async fn route_strict_json_schema_supported_schema_passes_100_runs() {
14043 let request_body = json!({
14044 "model": "stub-model",
14045 "messages": [{"role": "user", "content": "Return an answer object."}],
14046 "response_format": {
14047 "type": "json_schema",
14048 "json_schema": {
14049 "name": "answer",
14050 "strict": true,
14051 "schema": {
14052 "type": "object",
14053 "properties": {"answer": {"type": "string"}},
14054 "required": ["answer"]
14055 }
14056 }
14057 }
14058 });
14059 let router = router_with_stub("{\"answer\":\"yes\"}");
14060 for run in 0..100 {
14061 let response =
14062 post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
14063 assert_eq!(
14064 response.status(),
14065 AxumStatusCode::OK,
14066 "strict schema run {run} returned non-200"
14067 );
14068 let body = response_json(response).await;
14069 let content = body["choices"][0]["message"]["content"]
14070 .as_str()
14071 .unwrap_or("");
14072 assert_eq!(
14073 content, "{\"answer\":\"yes\"}",
14074 "strict schema run {run} returned unexpected content"
14075 );
14076 let parsed: serde_json::Value =
14077 serde_json::from_str(content).expect("strict content JSON");
14078 assert_eq!(parsed["answer"], "yes");
14079 }
14080 }
14081
14082 #[test]
14083 fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
14084 let cache = CacheRuntimeState::default();
14085 let policy = CachePolicy {
14086 prefix_cache_enabled: true,
14087 session_cache_mode: "memory".to_string(),
14088 session_cache_max_entries: 128,
14089 session_cache_max_tokens: 4096,
14090 };
14091 cache.record_prefix_prompt("alpha beta gamma", &policy);
14092 cache.record_prefix_prompt("alpha beta delta", &policy);
14093
14094 let engine_snapshot = json!({
14095 "position": "real-kv-reuse",
14096 "source": "llama-family-paged-block-prefix-cache",
14097 "enabled": true,
14098 "hits": 7,
14099 "misses": 3,
14100 "evictions": 1,
14101 "saved_prefill_tokens": 64,
14102 "entries": 5,
14103 "bytes": 8192,
14104 "block_size": 16,
14105 "kv_dtype": "fp16",
14106 "selected_pipeline_mode": "batch",
14107 "selected_stage_bridge": "host",
14108 "stage_count": 2,
14109 });
14110
14111 let health = cache.health_json(&policy, Some(&engine_snapshot));
14112 let prefix = &health["prefix_cache"];
14113 assert_eq!(prefix["position"], "real-kv-reuse");
14114 assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
14115 assert_eq!(prefix["hits"], 7);
14116 assert_eq!(prefix["misses"], 3);
14117 assert_eq!(prefix["evictions"], 1);
14118 assert_eq!(prefix["saved_prefill_tokens"], 64);
14119 assert_eq!(prefix["entries"], 5);
14120 assert_eq!(prefix["bytes"], 8192);
14121 assert_eq!(prefix["block_size"], 16);
14122 assert_eq!(prefix["kv_dtype"], "fp16");
14123 assert_eq!(prefix["selected_pipeline_mode"], "batch");
14124 assert_eq!(prefix["selected_stage_bridge"], "host");
14125 assert_eq!(prefix["stage_count"], 2);
14126
14127 let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
14128 assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
14129 assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
14130 assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
14131 assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
14132 assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
14133 }
14134
14135 #[tokio::test]
14136 async fn strict_json_schema_invalid_model_output_fails_before_response() {
14137 let request = chat_request(json!({
14138 "response_format": {
14139 "type": "json_schema",
14140 "json_schema": {
14141 "name": "answer",
14142 "strict": true,
14143 "schema": {
14144 "type": "object",
14145 "properties": {"answer": {"type": "string"}},
14146 "required": ["answer"]
14147 }
14148 }
14149 }
14150 }));
14151 let err = chat_completions_handler(
14152 State(state_with_stub("not json")),
14153 HeaderMap::new(),
14154 Ok(Json(request)),
14155 )
14156 .await
14157 .expect_err("invalid strict response should fail");
14158 let (status, body) = error_json(err).await;
14159 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14160 assert_eq!(body["error"]["type"], "internal_server_error");
14161 assert!(body["error"]["message"]
14162 .as_str()
14163 .unwrap()
14164 .contains("json_schema.strict"));
14165 }
14166
14167 #[tokio::test]
14168 async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
14169 let request = chat_request(json!({
14170 "response_format": {
14171 "type": "json_schema",
14172 "json_schema": {
14173 "name": "answer",
14174 "strict": true,
14175 "schema": {
14176 "type": "object",
14177 "properties": {"answer": {"type": "string"}},
14178 "required": ["answer"]
14179 }
14180 }
14181 }
14182 }));
14183 let err = chat_completions_handler(
14184 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
14185 HeaderMap::new(),
14186 Ok(Json(request)),
14187 )
14188 .await
14189 .expect_err("strict schema should fail fenced JSON instead of repairing it");
14190 let (status, body) = error_json(err).await;
14191 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14192 assert_eq!(body["error"]["type"], "internal_server_error");
14193 assert!(body["error"]["message"]
14194 .as_str()
14195 .unwrap()
14196 .contains("json_schema.strict: invalid JSON"));
14197 }
14198}