1use crate::{
7 chat_template::{
8 render_chat_prompt_with_model_template_options_and_compatibility_with_prefill,
9 render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill,
10 ChatTemplateOptions, ModelChatTemplate, ModelReasoningProtocol, ReasoningEffort,
11 },
12 model_registry::{LoraAdapterModel, ServedModelKind, ServedModelRegistry},
13 openai::*,
14 traits::HttpServer,
15 types::*,
16};
17use async_trait::async_trait;
18use axum::{
19 extract::{multipart::MultipartRejection, rejection::JsonRejection, State},
20 http::{HeaderMap, StatusCode as AxumStatusCode},
21 response::{sse::Event, IntoResponse, Response, Sse},
22 routing::{get, post},
23 Json, Router,
24};
25use ferrum_bench_core::{
26 BenchmarkRequestCorrelation, BENCHMARK_CELL_ID_HEADER, BENCHMARK_PHASE_HEADER,
27 BENCHMARK_REPEAT_INDEX_HEADER, BENCHMARK_REQUEST_INDEX_HEADER, BENCHMARK_RUN_ID_HEADER,
28};
29use ferrum_interfaces::engine::{EmbedEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine};
30use ferrum_types::{
31 has_unclosed_model_reasoning_block, model_reasoning_markers,
32 parse_harmony_response_for_finish_reason, parse_model_reasoning_response,
33 should_defer_model_reasoning_stream_delta, EngineMetrics, EngineStatus, FerrumConfigBuilder,
34 FerrumError as Error, FerrumProfileEvent, FinishReason, InferenceExecutionEvidence,
35 InferenceRequest, InferenceResponse, ModelId, ModelOutputProtocol, NativeChatOutputProjector,
36 ParsedReasoningResponse, Priority, ProcessMemoryObservation, ProcessMemorySample,
37 ProcessMemorySampler, ProfileEntrypoint, ProfileError, ProfileEventKind, ProfileStatus,
38 ReplayReference, RequestId, ResolvedFerrumConfig, ResourceAction, ResourceTraceEvent,
39 ResponseCompletionBoundary, RuntimeConfigSnapshot, SamplingParams, StructuredOutputStart,
40 TokenId, TokenUsage, DEFAULT_CHAT_REPETITION_PENALTY, DEFAULT_MAX_TOKENS_METADATA_KEY,
41 OBSERVABILITY_PROFILE_SCHEMA_VERSION, PROMPT_OPENED_REASONING_METADATA_KEY, THINK_END_TAG,
42 THINK_START_TAG,
43};
44use sha2::{Digest, Sha256};
45use std::{
46 collections::{BTreeMap, HashMap},
47 error::Error as StdError,
48 fs,
49 path::{Path, PathBuf},
50 sync::{
51 atomic::{AtomicBool, Ordering},
52 Arc, Mutex, OnceLock,
53 },
54 time::Instant,
55};
56use tokio::sync::{mpsc, Notify};
57use tokio_stream::StreamExt;
58use tower::ServiceBuilder;
59use tower_http::{cors::CorsLayer, trace::TraceLayer};
60use tracing::{debug, error, info, span, warn, Level};
61use uuid::Uuid;
62
63mod responses;
64
65const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
66const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
67const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 4096;
68const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
69const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
70const MAX_CACHED_JSON_SCHEMA_VALIDATORS: usize = 64;
71const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
72 &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
73const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
74static JSON_SCHEMA_VALIDATOR_CACHE: OnceLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
75 OnceLock::new();
76
77pub fn default_chat_sampling_params() -> SamplingParams {
81 SamplingParams {
82 max_tokens: DEFAULT_COMPLETION_MAX_TOKENS as usize,
83 temperature: DEFAULT_SAMPLING_TEMPERATURE,
84 top_p: DEFAULT_SAMPLING_TOP_P,
85 repetition_penalty: DEFAULT_CHAT_REPETITION_PENALTY,
86 ..SamplingParams::default()
87 }
88}
89
90#[derive(Debug, Clone)]
91struct CachePolicy {
92 prefix_cache_enabled: bool,
93 session_cache_mode: String,
94 session_cache_max_entries: usize,
95 session_cache_max_tokens: usize,
96}
97
98impl CachePolicy {
99 fn current() -> Self {
100 Self {
101 prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
102 .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
103 .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
104 .unwrap_or(false),
105 session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
106 .unwrap_or_else(|_| "off".to_string())
107 .to_ascii_lowercase(),
108 session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
109 session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
110 }
111 }
112
113 fn session_memory_enabled(&self) -> bool {
114 self.session_cache_mode == "memory"
115 }
116}
117
118fn env_bool(key: &str) -> Option<bool> {
119 match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
120 "1" | "true" | "yes" | "on" => Some(true),
121 "0" | "false" | "no" | "off" => Some(false),
122 _ => None,
123 }
124}
125
126fn env_usize(key: &str) -> Option<usize> {
127 std::env::var(key).ok()?.parse().ok()
128}
129
130static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
132 std::sync::OnceLock::new();
133
134pub fn init_prometheus_recorder() {
139 PROM_HANDLE.get_or_init(|| {
140 let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
141 let handle = builder
142 .install_recorder()
143 .expect("Failed to install Prometheus recorder");
144 info!("Prometheus metrics recorder installed");
145 handle
146 });
147}
148
149pub struct AxumServer {
155 state: AppState,
156 config: ServerConfig,
157 lifecycle: Arc<AxumServerLifecycle>,
158}
159
160#[derive(Default)]
161struct AxumServerLifecycle {
162 shutdown_requested: AtomicBool,
163 running: AtomicBool,
164 engines_stopped: AtomicBool,
165 shutdown_notify: Notify,
166 stopped_notify: Notify,
167 stop_lock: tokio::sync::Mutex<()>,
168}
169
170impl AxumServerLifecycle {
171 fn request_shutdown(&self) {
172 self.shutdown_requested.store(true, Ordering::Release);
173 self.shutdown_notify.notify_waiters();
174 }
175
176 async fn wait_for_shutdown(&self) {
177 while !self.shutdown_requested.load(Ordering::Acquire) {
178 self.shutdown_notify.notified().await;
179 }
180 }
181
182 async fn wait_until_stopped(&self) {
183 while self.running.load(Ordering::Acquire) {
184 self.stopped_notify.notified().await;
185 }
186 }
187}
188
189struct AxumServerRunGuard {
190 lifecycle: Arc<AxumServerLifecycle>,
191}
192
193impl Drop for AxumServerRunGuard {
194 fn drop(&mut self) {
195 self.lifecycle.running.store(false, Ordering::Release);
196 self.lifecycle.stopped_notify.notify_waiters();
197 }
198}
199
200fn single_model_registry(engine_model_id: ModelId, kind: ServedModelKind) -> ServedModelRegistry {
201 let public_name = engine_model_id.to_string();
202 ServedModelRegistry::try_new(engine_model_id, kind, vec![public_name], vec![])
203 .expect("engine config must contain a valid model id")
204}
205
206impl AxumServer {
207 pub fn from_state(state: AppState) -> Self {
209 Self {
210 state,
211 config: ServerConfig::default(),
212 lifecycle: Arc::new(AxumServerLifecycle::default()),
213 }
214 }
215
216 pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
218 Self::from_state(AppState::default().with_llm(engine))
219 }
220
221 pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
223 Self::from_state(AppState::default().with_embed(engine))
224 }
225
226 pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
229 Self::from_state(AppState::default().with_transcribe(engine))
230 }
231
232 pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
234 Self::from_state(AppState::default().with_tts(engine))
235 }
236
237 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
241 self.state = self.state.with_auto_config(auto_config);
242 self
243 }
244
245 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
248 self.state = self.state.with_prompt_template(prompt_template);
249 self
250 }
251
252 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
255 self.state = self.state.with_default_enable_thinking(enable_thinking);
256 self
257 }
258
259 pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
262 self.state = self.state.with_interleaved_system_coalescing(enabled);
263 self
264 }
265
266 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
270 self.state = self.state.with_served_model_registry(registry);
271 self
272 }
273
274 pub fn with_lora_adapters(
276 mut self,
277 base_model_id: impl Into<String>,
278 adapters: Vec<LoraAdapterModel>,
279 ) -> ferrum_types::Result<Self> {
280 let base_model_id = base_model_id.into();
281 let registry = if self.state.served_model_registry.is_empty() {
282 ServedModelRegistry::try_new(
283 base_model_id.clone(),
284 ServedModelKind::Llm,
285 vec![base_model_id],
286 adapters,
287 )
288 } else {
289 self.state
290 .served_model_registry
291 .try_with_lora_adapters(&base_model_id, adapters)
292 }
293 .map_err(|error| Error::config(error.to_string()))?;
294 self.state = self.state.with_served_model_registry(registry);
295 Ok(self)
296 }
297
298 async fn shutdown_loaded_engines(&self) -> ferrum_types::Result<()> {
299 let mut first_error = None;
300 if let Some(engine) = &self.state.llm {
301 if let Err(error) = engine.shutdown().await {
302 first_error = Some(error);
303 }
304 }
305 if let Some(engine) = &self.state.embed {
306 if let Err(error) = engine.shutdown().await {
307 if first_error.is_none() {
308 first_error = Some(error);
309 }
310 }
311 }
312 if let Some(engine) = &self.state.transcribe {
313 if let Err(error) = engine.shutdown().await {
314 if first_error.is_none() {
315 first_error = Some(error);
316 }
317 }
318 }
319 if let Some(engine) = &self.state.tts {
320 if let Err(error) = engine.shutdown().await {
321 if first_error.is_none() {
322 first_error = Some(error);
323 }
324 }
325 }
326 first_error.map_or(Ok(()), Err)
327 }
328
329 #[allow(dead_code)]
331 fn build_router(&self) -> Router {
332 self.build_router_with_state(self.state.clone())
333 }
334
335 fn build_router_with_state(&self, app_state: AppState) -> Router {
336 Router::new()
337 .route("/v1/chat/completions", post(chat_completions_handler))
339 .route("/v1/responses", post(responses::responses_handler))
340 .route("/v1/completions", post(completions_handler))
341 .route("/v1/embeddings", post(embeddings_handler))
342 .route("/v1/audio/transcriptions", post(transcriptions_handler))
343 .route("/v1/audio/speech", post(speech_handler))
344 .route("/v1/models", get(models_handler))
345 .route("/health", get(health_handler))
347 .route("/metrics", get(metrics_handler))
348 .route("/", get(root_handler))
349 .layer(
351 ServiceBuilder::new()
352 .layer(TraceLayer::new_for_http())
353 .layer(CorsLayer::permissive()), )
355 .with_state(app_state)
356 }
357}
358
359#[derive(Clone, Default)]
363pub struct AppState {
364 pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
365 pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
366 pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
367 pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
368 pub auto_config: Option<ResolvedFerrumConfig>,
369 pub prompt_template: Option<Arc<ModelChatTemplate>>,
370 pub default_enable_thinking: Option<bool>,
371 interleaved_system_coalescing: Option<bool>,
372 pub served_model_registry: Arc<ServedModelRegistry>,
373 pub request_dump_dir: Option<Arc<PathBuf>>,
374 pub profile_jsonl: Option<Arc<PathBuf>>,
375 pub profile_detail: ferrum_types::ObservabilityProfileDetail,
376 pub memory_profile_jsonl: Option<Arc<PathBuf>>,
377 pub first_request_memory_recorded: Arc<AtomicBool>,
378 cache: Arc<CacheRuntimeState>,
379}
380
381impl AppState {
382 pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
383 if self.served_model_registry.is_empty() {
384 self.served_model_registry = Arc::new(single_model_registry(
385 engine.config().model.model_id.clone(),
386 ServedModelKind::Llm,
387 ));
388 }
389 self.llm = Some(engine);
390 self
391 }
392 pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
393 if self.served_model_registry.is_empty() {
394 self.served_model_registry = Arc::new(single_model_registry(
395 engine.config().model.model_id.clone(),
396 ServedModelKind::Embedding,
397 ));
398 }
399 self.embed = Some(engine);
400 self
401 }
402 pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
403 if self.served_model_registry.is_empty() {
404 self.served_model_registry = Arc::new(single_model_registry(
405 engine.config().model.model_id.clone(),
406 ServedModelKind::Transcription,
407 ));
408 }
409 self.transcribe = Some(engine);
410 self
411 }
412 pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
413 if self.served_model_registry.is_empty() {
414 self.served_model_registry = Arc::new(single_model_registry(
415 engine.config().model.model_id.clone(),
416 ServedModelKind::Speech,
417 ));
418 }
419 self.tts = Some(engine);
420 self
421 }
422
423 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
424 self.auto_config = Some(auto_config);
425 self
426 }
427
428 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
429 self.prompt_template = prompt_template.map(Arc::new);
430 self
431 }
432
433 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
434 self.default_enable_thinking = enable_thinking;
435 self
436 }
437
438 pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
439 self.interleaved_system_coalescing = Some(enabled);
440 self
441 }
442
443 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
444 self.served_model_registry = Arc::new(registry);
445 self
446 }
447
448 pub fn with_request_dump_dir(mut self, request_dump_dir: Option<PathBuf>) -> Self {
449 self.request_dump_dir = request_dump_dir.map(Arc::new);
450 self
451 }
452
453 pub fn with_profile_jsonl(mut self, profile_jsonl: Option<PathBuf>) -> Self {
454 self.profile_jsonl = profile_jsonl.map(Arc::new);
455 self
456 }
457
458 pub fn with_profile_detail(
459 mut self,
460 profile_detail: ferrum_types::ObservabilityProfileDetail,
461 ) -> Self {
462 self.profile_detail = profile_detail;
463 self
464 }
465
466 pub fn with_memory_profile_jsonl(mut self, memory_profile_jsonl: Option<PathBuf>) -> Self {
467 self.memory_profile_jsonl = memory_profile_jsonl.map(Arc::new);
468 self
469 }
470
471 async fn status(&self) -> EngineStatus {
474 if let Some(e) = &self.llm {
475 return e.status().await;
476 }
477 if let Some(e) = &self.embed {
478 return e.status().await;
479 }
480 if let Some(e) = &self.transcribe {
481 return e.status().await;
482 }
483 if let Some(e) = &self.tts {
484 return e.status().await;
485 }
486 EngineStatus {
487 is_ready: false,
488 loaded_models: vec![],
489 active_requests: 0,
490 queued_requests: 0,
491 memory_usage: ferrum_types::MemoryUsage {
492 total_bytes: 0,
493 used_bytes: 0,
494 free_bytes: 0,
495 gpu_memory_bytes: None,
496 cpu_memory_bytes: None,
497 cache_memory_bytes: 0,
498 utilization_percent: 0.0,
499 },
500 uptime_seconds: 0,
501 last_heartbeat: chrono::Utc::now(),
502 version: env!("CARGO_PKG_VERSION").to_string(),
503 }
504 }
505
506 fn metrics(&self) -> EngineMetrics {
507 if let Some(e) = &self.llm {
508 return e.metrics();
509 }
510 if let Some(e) = &self.embed {
511 return e.metrics();
512 }
513 if let Some(e) = &self.transcribe {
514 return e.metrics();
515 }
516 if let Some(e) = &self.tts {
517 return e.metrics();
518 }
519 EngineMetrics {
520 total_requests: 0,
521 successful_requests: 0,
522 failed_requests: 0,
523 avg_request_latency_ms: 0.0,
524 p95_request_latency_ms: 0.0,
525 p99_request_latency_ms: 0.0,
526 throughput_rps: 0.0,
527 tokens_per_second: 0.0,
528 queue_metrics: Default::default(),
529 resource_utilization: Default::default(),
530 error_stats: Default::default(),
531 performance_breakdown: Default::default(),
532 }
533 }
534}
535
536#[derive(Default)]
537struct CacheRuntimeState {
538 stats: Mutex<CacheStats>,
539 prefix_prompts: Mutex<HashMap<String, usize>>,
540 sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
541}
542
543#[derive(Debug, Clone, Default)]
544struct CacheStats {
545 prefix_hits: u64,
546 prefix_misses: u64,
547 prefix_evictions: u64,
548 prefix_saved_prefill_tokens: u64,
549 prefix_entries: u64,
550 prefix_bytes: u64,
551 session_hits: u64,
552 session_misses: u64,
553 session_evictions: u64,
554 session_entries: u64,
555 session_tokens: u64,
556}
557
558#[derive(Clone)]
559struct SessionContext {
560 id: String,
561 prior_messages: Vec<ChatMessage>,
562 incoming_messages: Vec<ChatMessage>,
563}
564
565impl CacheRuntimeState {
566 fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
567 if !policy.prefix_cache_enabled {
568 return;
569 }
570
571 let prompt_tokens = approx_tokens(prompt);
572 let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
573 let saved_tokens = prompts
574 .keys()
575 .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
576 .max()
577 .unwrap_or(0);
578
579 let mut stats = self.stats.lock().expect("cache stats lock");
580 if saved_tokens > 0 {
581 stats.prefix_hits += 1;
582 stats.prefix_saved_prefill_tokens += saved_tokens as u64;
583 } else {
584 stats.prefix_misses += 1;
585 }
586
587 let max_entries = policy.session_cache_max_entries.max(1);
588 if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
589 if let Some(key) = prompts.keys().next().cloned() {
590 prompts.remove(&key);
591 stats.prefix_evictions += 1;
592 }
593 }
594 prompts.insert(prompt.to_string(), prompt_tokens);
595 stats.prefix_entries = prompts.len() as u64;
596 stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
597 }
598
599 fn prepare_session_request(
600 &self,
601 request: &mut ChatCompletionsRequest,
602 headers: &HeaderMap,
603 policy: &CachePolicy,
604 ) -> Option<SessionContext> {
605 let session_id = request_session_id(headers, request)?;
606 if !policy.session_memory_enabled() {
607 return None;
608 }
609
610 let incoming_messages = request.messages.clone();
611 let prior_messages = {
612 let sessions = self.sessions.lock().expect("session cache lock");
613 sessions.get(&session_id).cloned().unwrap_or_default()
614 };
615 {
616 let mut stats = self.stats.lock().expect("cache stats lock");
617 if prior_messages.is_empty() {
618 stats.session_misses += 1;
619 } else {
620 stats.session_hits += 1;
621 let mut merged = prior_messages.clone();
622 merged.extend(request.messages.clone());
623 request.messages = merged;
624 }
625 }
626
627 Some(SessionContext {
628 id: session_id,
629 prior_messages,
630 incoming_messages,
631 })
632 }
633
634 fn update_session(
635 &self,
636 context: Option<SessionContext>,
637 assistant_message: ChatMessage,
638 policy: &CachePolicy,
639 ) {
640 let Some(context) = context else {
641 return;
642 };
643 if !policy.session_memory_enabled() {
644 return;
645 }
646
647 let mut history = context.prior_messages;
648 history.extend(context.incoming_messages);
649 history.push(assistant_message);
650 trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
651
652 let mut sessions = self.sessions.lock().expect("session cache lock");
653 if !sessions.contains_key(&context.id)
654 && sessions.len() >= policy.session_cache_max_entries.max(1)
655 {
656 if let Some(evict_key) = sessions.keys().next().cloned() {
657 sessions.remove(&evict_key);
658 self.stats
659 .lock()
660 .expect("cache stats lock")
661 .session_evictions += 1;
662 }
663 }
664 sessions.insert(context.id, history);
665
666 let entries = sessions.len() as u64;
667 let tokens = sessions
668 .values()
669 .map(|messages| {
670 messages
671 .iter()
672 .map(|msg| approx_tokens(&msg.content))
673 .sum::<usize>()
674 })
675 .sum::<usize>() as u64;
676 let mut stats = self.stats.lock().expect("cache stats lock");
677 stats.session_entries = entries;
678 stats.session_tokens = tokens;
679 }
680
681 fn stats(&self) -> CacheStats {
682 let mut stats = self.stats.lock().expect("cache stats lock").clone();
683 stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
684 let sessions = self.sessions.lock().expect("session cache lock");
685 stats.session_entries = sessions.len() as u64;
686 stats.session_tokens = sessions
687 .values()
688 .map(|messages| {
689 messages
690 .iter()
691 .map(|msg| approx_tokens(&msg.content))
692 .sum::<usize>()
693 })
694 .sum::<usize>() as u64;
695 stats
696 }
697
698 fn health_json(
699 &self,
700 policy: &CachePolicy,
701 engine_prefix_cache: Option<&serde_json::Value>,
702 ) -> serde_json::Value {
703 let stats = self.stats();
704 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
705 let prefix_misses =
706 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
707 let prefix_evictions =
708 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
709 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
710 .unwrap_or(stats.prefix_saved_prefill_tokens);
711 let prefix_entries =
712 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
713 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
714 let mut prefix_cache = serde_json::json!({
715 "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
716 "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
717 "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
718 "entries": prefix_entries,
719 "hits": prefix_hits,
720 "misses": prefix_misses,
721 "evictions": prefix_evictions,
722 "saved_prefill_tokens": prefix_saved,
723 "bytes": prefix_bytes,
724 "block_size": engine_u64(engine_prefix_cache, "block_size"),
725 "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
726 });
727 if let (Some(engine), Some(prefix)) = (
728 engine_prefix_cache.and_then(|value| value.as_object()),
729 prefix_cache.as_object_mut(),
730 ) {
731 for (key, value) in engine {
732 prefix.entry(key.clone()).or_insert_with(|| value.clone());
733 }
734 }
735 serde_json::json!({
736 "prefix_cache": prefix_cache,
737 "session_cache": {
738 "mode": policy.session_cache_mode,
739 "entries": stats.session_entries,
740 "hits": stats.session_hits,
741 "misses": stats.session_misses,
742 "evictions": stats.session_evictions,
743 "tokens": stats.session_tokens,
744 "max_entries": policy.session_cache_max_entries,
745 "max_tokens": policy.session_cache_max_tokens,
746 }
747 })
748 }
749
750 fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
751 let stats = self.stats();
752 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
753 let prefix_misses =
754 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
755 let prefix_evictions =
756 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
757 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
758 .unwrap_or(stats.prefix_saved_prefill_tokens);
759 let prefix_entries =
760 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
761 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
762 format!(
763 concat!(
764 "ferrum_prefix_cache_hits_total {}\n",
765 "ferrum_prefix_cache_misses_total {}\n",
766 "ferrum_prefix_cache_evictions_total {}\n",
767 "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
768 "ferrum_prefix_cache_entries {}\n",
769 "ferrum_prefix_cache_bytes {}\n",
770 "ferrum_session_cache_hits_total {}\n",
771 "ferrum_session_cache_misses_total {}\n",
772 "ferrum_session_cache_evictions_total {}\n",
773 "ferrum_session_cache_entries {}\n",
774 "ferrum_session_cache_tokens {}\n"
775 ),
776 prefix_hits,
777 prefix_misses,
778 prefix_evictions,
779 prefix_saved,
780 prefix_entries,
781 prefix_bytes,
782 stats.session_hits,
783 stats.session_misses,
784 stats.session_evictions,
785 stats.session_entries,
786 stats.session_tokens,
787 )
788 }
789}
790
791fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
792 snapshot?.get(key)?.as_u64()
793}
794
795fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
796 snapshot?.get(key)?.as_bool()
797}
798
799fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
800 snapshot?.get(key)?.as_str()
801}
802
803fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
804 match auto_config {
805 Some(auto_config) => auto_config.effective_config_document(),
806 None => {
807 match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
808 Ok(auto_config) => auto_config.effective_config_document(),
809 Err(err) => serde_json::json!({
810 "schema_version": 1,
811 "error": err.to_string(),
812 }),
813 }
814 }
815 }
816}
817
818fn admission_health_json(
819 engine_status: &EngineStatus,
820 scheduler_metrics: &EngineMetrics,
821 auto_config: &serde_json::Value,
822 runtime_snapshot: Option<&ferrum_types::ExecutorAdmissionSnapshot>,
823 runtime_error: Option<&str>,
824) -> serde_json::Value {
825 let configured = auto_config
826 .get("admission")
827 .and_then(|value| value.as_object());
828 let preflight_effective_max_concurrent = configured
829 .and_then(|value| value.get("effective_max_concurrent"))
830 .and_then(|value| value.as_u64());
831 let effective_max_concurrent = if runtime_error.is_some() {
832 None
833 } else {
834 Some(
835 runtime_snapshot
836 .map(|snapshot| u64::from(snapshot.maximum_active_sequences()))
837 .or(preflight_effective_max_concurrent)
838 .unwrap_or_else(|| {
839 (engine_status.active_requests + engine_status.queued_requests)
840 .max(1)
841 .try_into()
842 .unwrap_or(u64::MAX)
843 }),
844 )
845 };
846 let active_sequences = runtime_error.is_none().then(|| {
847 runtime_snapshot
848 .map(|snapshot| u64::from(snapshot.active_sequences()))
849 .unwrap_or_else(|| engine_status.active_requests as u64)
850 });
851 let waiting_requests = runtime_error.is_none().then(|| {
852 runtime_snapshot
853 .map(|snapshot| u64::from(snapshot.waiting_requests()))
854 .unwrap_or_else(|| engine_status.queued_requests as u64)
855 });
856 serde_json::json!({
857 "schema_version": 2,
858 "source": if runtime_error.is_some() {
859 "runtime_error"
860 } else if runtime_snapshot.is_some() {
861 "runtime_executor"
862 } else {
863 "startup_preflight_and_engine_status"
864 },
865 "runtime_snapshot_available": runtime_snapshot.is_some(),
866 "runtime_contract_error": runtime_error,
867 "resource_authority": runtime_snapshot
868 .and_then(|snapshot| serde_json::to_value(snapshot.resource_authority()).ok())
869 .unwrap_or(serde_json::Value::Null),
870 "effective_max_concurrent": effective_max_concurrent,
871 "maximum_active_sequences": runtime_snapshot
872 .map(|snapshot| u64::from(snapshot.maximum_active_sequences())),
873 "maximum_scheduled_tokens": runtime_snapshot
874 .map(|snapshot| snapshot.maximum_scheduled_tokens()),
875 "preflight_effective_max_concurrent": preflight_effective_max_concurrent,
876 "queue_depth": waiting_requests,
877 "active_sequences": active_sequences,
878 "active_prefill": runtime_snapshot
879 .map(|snapshot| u64::from(snapshot.active_prefill_sequences())),
880 "active_decode": runtime_snapshot
881 .map(|snapshot| u64::from(snapshot.active_decode_sequences())),
882 "current_batch_size": runtime_snapshot
883 .and_then(|snapshot| snapshot.current_batch_size())
884 .map(u64::from),
885 "capacity_blocked_requests": runtime_snapshot
886 .and_then(|snapshot| snapshot.capacity_blocked_requests())
887 .map(u64::from),
888 "rejected_requests_total": 0u64,
889 "failed_requests_total": scheduler_metrics.failed_requests,
890 "completed_requests_total": scheduler_metrics.successful_requests,
891 "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
892 "scheduler_policy": configured
893 .and_then(|value| value.get("scheduler_policy"))
894 .and_then(|value| value.as_str())
895 .unwrap_or("unknown"),
896 "phase_detail_source": if runtime_snapshot.is_some() {
897 "scheduler_request_index_single_read"
898 } else {
899 "unavailable"
900 },
901 })
902}
903
904fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
905 let snapshot_available = u8::from(
906 admission
907 .get("runtime_snapshot_available")
908 .and_then(serde_json::Value::as_bool)
909 .unwrap_or(false),
910 );
911 let mut output = format!("ferrum_admission_runtime_snapshot_available {snapshot_available}\n");
912 for (field, metric) in [
913 (
914 "effective_max_concurrent",
915 "ferrum_admission_effective_max_concurrent",
916 ),
917 (
918 "maximum_active_sequences",
919 "ferrum_admission_maximum_active_sequences",
920 ),
921 (
922 "maximum_scheduled_tokens",
923 "ferrum_admission_maximum_scheduled_tokens",
924 ),
925 ("queue_depth", "ferrum_admission_queue_depth"),
926 (
927 "capacity_blocked_requests",
928 "ferrum_admission_capacity_blocked_requests",
929 ),
930 ("active_sequences", "ferrum_admission_active_sequences"),
931 ("active_prefill", "ferrum_admission_active_prefill"),
932 ("active_decode", "ferrum_admission_active_decode"),
933 ("current_batch_size", "ferrum_admission_current_batch_size"),
934 (
935 "rejected_requests_total",
936 "ferrum_admission_rejected_requests_total",
937 ),
938 (
939 "failed_requests_total",
940 "ferrum_admission_failed_requests_total",
941 ),
942 (
943 "completed_requests_total",
944 "ferrum_admission_completed_requests_total",
945 ),
946 ] {
947 if let Some(value) = admission.get(field).and_then(serde_json::Value::as_u64) {
948 output.push_str(&format!("{metric} {value}\n"));
949 }
950 }
951 output
952}
953
954fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
955 headers
956 .get(FERRUM_SESSION_HEADER)
957 .and_then(|value| value.to_str().ok())
958 .map(str::trim)
959 .filter(|value| !value.is_empty())
960 .map(str::to_string)
961 .or_else(|| {
962 request
963 .metadata
964 .as_ref()
965 .and_then(|metadata| metadata.get("ferrum_session_id"))
966 .and_then(|value| value.as_str())
967 .map(str::trim)
968 .filter(|value| !value.is_empty())
969 .map(str::to_string)
970 })
971}
972
973fn benchmark_request_correlation(
974 headers: &HeaderMap,
975) -> std::result::Result<Option<BenchmarkRequestCorrelation>, ServerError> {
976 let header_value = |name: &'static str| {
977 headers
978 .get(name)
979 .map(|value| {
980 value.to_str().map_err(|_| {
981 ServerError::invalid_request(
982 format!("{name} must contain visible ASCII text"),
983 Some(name),
984 )
985 })
986 })
987 .transpose()
988 };
989 BenchmarkRequestCorrelation::from_header_values(
990 header_value(BENCHMARK_RUN_ID_HEADER)?,
991 header_value(BENCHMARK_CELL_ID_HEADER)?,
992 header_value(BENCHMARK_REPEAT_INDEX_HEADER)?,
993 header_value(BENCHMARK_PHASE_HEADER)?,
994 header_value(BENCHMARK_REQUEST_INDEX_HEADER)?,
995 )
996 .map_err(|error| ServerError::invalid_request(error, Some(BENCHMARK_RUN_ID_HEADER)))
997}
998
999fn extend_benchmark_profile_attributes(
1000 attributes: &mut BTreeMap<String, serde_json::Value>,
1001 correlation: Option<&BenchmarkRequestCorrelation>,
1002) {
1003 let Some(correlation) = correlation else {
1004 return;
1005 };
1006 attributes.extend([
1007 (
1008 "benchmark_run_id".to_string(),
1009 serde_json::json!(correlation.benchmark_run_id),
1010 ),
1011 (
1012 "cell_id".to_string(),
1013 serde_json::json!(correlation.cell_id),
1014 ),
1015 (
1016 "repeat_index".to_string(),
1017 serde_json::json!(correlation.repeat_index),
1018 ),
1019 (
1020 "phase".to_string(),
1021 serde_json::json!(correlation.phase.as_str()),
1022 ),
1023 (
1024 "request_index".to_string(),
1025 serde_json::json!(correlation.request_index),
1026 ),
1027 ]);
1028}
1029
1030fn approx_tokens(text: &str) -> usize {
1031 approx_tokens_for_chars(text.chars().count())
1032}
1033
1034fn approx_tokens_for_chars(chars: usize) -> usize {
1035 (chars / 4).max(1)
1036}
1037
1038fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
1039 a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
1040}
1041
1042fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
1043 let max_tokens = max_tokens.max(1);
1044 while messages.len() > 1
1045 && messages
1046 .iter()
1047 .map(|msg| approx_tokens(&msg.content))
1048 .sum::<usize>()
1049 > max_tokens
1050 {
1051 messages.remove(0);
1052 }
1053}
1054
1055#[async_trait]
1056impl HttpServer for AxumServer {
1057 async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
1058 if self.lifecycle.shutdown_requested.load(Ordering::Acquire) {
1059 return Err(Error::internal(
1060 "cannot start Axum server after shutdown was requested",
1061 ));
1062 }
1063 self.lifecycle
1064 .running
1065 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1066 .map_err(|_| Error::internal("Axum server is already running"))?;
1067 let _run_guard = AxumServerRunGuard {
1068 lifecycle: Arc::clone(&self.lifecycle),
1069 };
1070 let addr = format!("{}:{}", config.host, config.port);
1071 info!("Starting Axum server on {}", addr);
1072
1073 let app = self.build_router_with_state(
1074 self.state
1075 .clone()
1076 .with_request_dump_dir(config.request_dump_dir.clone())
1077 .with_profile_jsonl(config.profile_jsonl.clone())
1078 .with_profile_detail(config.profile_detail)
1079 .with_memory_profile_jsonl(config.memory_profile_jsonl.clone()),
1080 );
1081 let listener = tokio::net::TcpListener::bind(&addr)
1082 .await
1083 .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
1084
1085 info!("Server listening on {}", addr);
1086
1087 let lifecycle = Arc::clone(&self.lifecycle);
1088 axum::serve(listener, app)
1089 .with_graceful_shutdown(async move { lifecycle.wait_for_shutdown().await })
1090 .await
1091 .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
1092
1093 Ok(())
1094 }
1095
1096 async fn stop(&self, timeout: std::time::Duration) -> ferrum_types::Result<()> {
1097 let _stop_guard = self.lifecycle.stop_lock.lock().await;
1098 info!("Stopping Axum server");
1099 self.lifecycle.request_shutdown();
1100
1101 let mut first_error = None;
1102 if self.lifecycle.running.load(Ordering::Acquire) {
1103 if tokio::time::timeout(timeout, self.lifecycle.wait_until_stopped())
1104 .await
1105 .is_err()
1106 {
1107 first_error = Some(Error::internal(format!(
1108 "Axum server did not drain within {} ms",
1109 timeout.as_millis()
1110 )));
1111 }
1112 }
1113
1114 if !self.lifecycle.engines_stopped.load(Ordering::Acquire) {
1115 match tokio::time::timeout(timeout, self.shutdown_loaded_engines()).await {
1116 Ok(Ok(())) => {
1117 self.lifecycle
1118 .engines_stopped
1119 .store(true, Ordering::Release);
1120 }
1121 Ok(Err(error)) => {
1122 if first_error.is_none() {
1123 first_error = Some(error);
1124 }
1125 }
1126 Err(_) => {
1127 if first_error.is_none() {
1128 first_error = Some(Error::internal(format!(
1129 "engine shutdown did not complete within {} ms",
1130 timeout.as_millis()
1131 )));
1132 }
1133 }
1134 }
1135 }
1136
1137 first_error.map_or(Ok(()), Err)
1138 }
1139
1140 fn is_running(&self) -> bool {
1141 self.lifecycle.running.load(Ordering::Acquire)
1142 }
1143
1144 fn address(&self) -> Option<std::net::SocketAddr> {
1145 format!("{}:{}", self.config.host, self.config.port)
1147 .parse()
1148 .ok()
1149 }
1150
1151 fn register_handler(
1152 &mut self,
1153 _path: &str,
1154 _method: HttpMethod,
1155 _handler: Box<dyn crate::traits::RequestHandler>,
1156 ) {
1157 unimplemented!("Dynamic handler registration not implemented in MVP")
1159 }
1160
1161 fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
1162 unimplemented!("Dynamic middleware registration not implemented in MVP")
1164 }
1165
1166 fn get_metrics(&self) -> ServerMetrics {
1167 ServerMetrics {
1169 total_requests: 0,
1170 requests_by_endpoint: std::collections::HashMap::new(),
1171 requests_by_status: std::collections::HashMap::new(),
1172 avg_response_time_ms: 0.0,
1173 p95_response_time_ms: 0.0,
1174 p99_response_time_ms: 0.0,
1175 active_connections: 0,
1176 bytes_sent: 0,
1177 bytes_received: 0,
1178 error_rate: 0.0,
1179 uptime_seconds: 0,
1180 }
1181 }
1182
1183 async fn health_check(&self) -> HealthStatus {
1184 HealthStatus::Healthy
1185 }
1186}
1187
1188async fn chat_completions_handler(
1190 State(state): State<AppState>,
1191 headers: HeaderMap,
1192 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1193) -> std::result::Result<Response, ServerError> {
1194 chat_completions_handler_with_phases(State(state), headers, request, None).await
1195}
1196
1197async fn chat_completions_handler_with_phases(
1198 State(state): State<AppState>,
1199 headers: HeaderMap,
1200 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1201 mut message_phases: Option<Vec<Option<AssistantMessagePhase>>>,
1202) -> std::result::Result<Response, ServerError> {
1203 let Json(mut request) = request.map_err(|error| {
1204 ServerError::invalid_request(
1205 format!(
1206 "invalid chat completions request: {}",
1207 json_rejection_detail(&error)
1208 ),
1209 None,
1210 )
1211 })?;
1212 let benchmark_correlation = benchmark_request_correlation(&headers)?;
1213 let cache_policy = CachePolicy::current();
1214 if message_phases
1215 .as_ref()
1216 .is_some_and(|phases| phases.len() != request.messages.len())
1217 {
1218 return Err(ServerError::InternalError(
1219 "Responses message phase metadata did not match input history".to_string(),
1220 ));
1221 }
1222 let session_context =
1223 state
1224 .cache
1225 .prepare_session_request(&mut request, &headers, &cache_policy);
1226 if let Some(phases) = &mut message_phases {
1227 let prepended = request
1228 .messages
1229 .len()
1230 .checked_sub(phases.len())
1231 .ok_or_else(|| {
1232 ServerError::InternalError(
1233 "session preparation shortened Responses input history".to_string(),
1234 )
1235 })?;
1236 phases.splice(0..0, std::iter::repeat(None).take(prepended));
1237 }
1238
1239 let span = span!(Level::INFO, "chat_completions", model = %request.model);
1240 let _enter = span.enter();
1241
1242 info!(
1243 "Received chat completions request for model: {}",
1244 request.model
1245 );
1246 debug!("Request: {:?}", request);
1247
1248 validate_chat_request(&request)?;
1251 let (engine_model_id, lora_adapter) = resolve_request_model(
1252 &state.served_model_registry,
1253 &request.model,
1254 ServedModelKind::Llm,
1255 )?;
1256
1257 let mut inference_request = convert_chat_request_with_template_model_and_default(
1259 &request,
1260 &engine_model_id.0,
1261 state.prompt_template.as_deref(),
1262 state.default_enable_thinking,
1263 state.interleaved_system_coalescing.unwrap_or(true),
1264 message_phases.as_deref(),
1265 )
1266 .map_err(server_error_from_ferrum_error)?;
1267 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
1268 if state.request_dump_dir.is_some() {
1269 inference_request.evidence_request.capture_prompt_token_ids = true;
1270 }
1271 inference_request
1272 .evidence_request
1273 .capture_engine_token_timing = state.profile_detail.captures_engine_token_timing();
1274 state
1275 .cache
1276 .record_prefix_prompt(&inference_request.prompt, &cache_policy);
1277 if let Err(err) =
1278 write_chat_request_replay_bundle(&state, &headers, &request, &inference_request)
1279 {
1280 warn!("failed to write chat request replay bundle: {}", err);
1281 }
1282
1283 if request.stream.unwrap_or(false) {
1285 handle_chat_completions_stream(state, request, inference_request, benchmark_correlation)
1286 .await
1287 } else {
1288 handle_chat_completions_sync(
1289 state,
1290 request,
1291 inference_request,
1292 session_context,
1293 benchmark_correlation,
1294 )
1295 .await
1296 }
1297}
1298
1299fn json_rejection_detail(rejection: &JsonRejection) -> String {
1300 const MAX_DETAIL_CHARS: usize = 512;
1301
1302 let mut details = Vec::new();
1303 let mut current: Option<&(dyn StdError + 'static)> = Some(rejection);
1304 while let Some(error) = current {
1305 let detail = error.to_string();
1306 if !detail.is_empty() && details.last() != Some(&detail) {
1307 details.push(detail);
1308 }
1309 current = error.source();
1310 }
1311
1312 details.join(": ").chars().take(MAX_DETAIL_CHARS).collect()
1313}
1314
1315fn write_chat_request_replay_bundle(
1316 state: &AppState,
1317 headers: &HeaderMap,
1318 openai_request: &ChatCompletionsRequest,
1319 inference_request: &InferenceRequest,
1320) -> std::result::Result<(), String> {
1321 let Some(root) = state.request_dump_dir.as_ref() else {
1322 return Ok(());
1323 };
1324 let request_id = inference_request.id.to_string();
1325 let bundle_dir = root.join(&request_id);
1326 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1327
1328 let sanitized_body = sanitized_chat_request_body(openai_request);
1329 let replay_body_path = bundle_dir.join("replay_body.json");
1330 write_json_value(&replay_body_path, &sanitized_body)?;
1331 let engine_replay_argv = replay_bundle_argv(&bundle_dir);
1332 let output_text_body = format!(
1333 "[server request replay emitted before response]\nsha256={}\nchars=0\n",
1334 sha256_hex(b"")
1335 );
1336
1337 let request = serde_json::json!({
1338 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1339 "entrypoint": "serve",
1340 "request_id": request_id,
1341 "model": openai_request.model.clone(),
1342 "backend": "actual",
1343 "endpoint": "/v1/chat/completions",
1344 "method": "POST",
1345 "stream": openai_request.stream.unwrap_or(false),
1346 "actual_model_smoke": true,
1347 "sanitized": true,
1348 "http": {
1349 "method": "POST",
1350 "path": "/v1/chat/completions",
1351 "headers": sanitized_replay_headers(headers),
1352 "body": sanitized_body
1353 }
1354 });
1355 let files = [
1356 ("request.json", request),
1357 (
1358 "prompt_token_ids.json",
1359 serde_json::json!({
1360 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1361 "request_id": request_id,
1362 "model": openai_request.model.clone(),
1363 "tokenizer_or_model": openai_request.model.clone(),
1364 "token_ids": null,
1365 "token_count": null,
1366 "unavailable_reason": "server request replay captures the OpenAI body before prompt token ids are retained",
1367 "sanitized": true
1368 }),
1369 ),
1370 (
1371 "sampling_params.json",
1372 serde_json::json!({
1373 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1374 "request_id": request_id,
1375 "sampling_params": inference_request.sampling_params.clone(),
1376 "unavailable_reason": null
1377 }),
1378 ),
1379 (
1380 "runtime_effective_config.json",
1381 serde_json::json!({
1382 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1383 "request_id": request_id,
1384 "entrypoint": "serve",
1385 "endpoint": "/v1/chat/completions",
1386 "stream": openai_request.stream.unwrap_or(false),
1387 "request_dump_dir": root.to_string_lossy(),
1388 "sanitized": true
1389 }),
1390 ),
1391 (
1392 "backend_selection.json",
1393 serde_json::json!({
1394 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1395 "request_id": request_id,
1396 "backend": "actual",
1397 "model": openai_request.model.clone(),
1398 "actual_model_smoke": true
1399 }),
1400 ),
1401 (
1402 "output_token_ids.json",
1403 serde_json::json!({
1404 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1405 "request_id": request_id,
1406 "token_ids": [],
1407 "token_count": 0,
1408 "finish_reason": null,
1409 "unavailable_reason": "server request replay bundle is emitted at request admission in this WP9 slice"
1410 }),
1411 ),
1412 (
1413 "bad_output_scan.json",
1414 serde_json::json!({
1415 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1416 "request_id": request_id,
1417 "bad_output": false,
1418 "bad_text_count": 0,
1419 "reasons": [],
1420 "first_bad_text_span": null,
1421 "failure_kind": null,
1422 "output_chars": 0,
1423 "classified_output_sha256": sha256_hex(b""),
1424 "output_sha256": sha256_hex(output_text_body.as_bytes())
1425 }),
1426 ),
1427 (
1428 "replay.command.json",
1429 serde_json::json!({
1430 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1431 "request_id": request_id,
1432 "entrypoint": "serve",
1433 "command": replay_curl_command(&bundle_dir),
1434 "argv": replay_curl_argv(&bundle_dir),
1435 "bundle_dir": bundle_dir.to_string_lossy(),
1436 "requires_running_server": true,
1437 "engine_replay": {
1438 "mode": "bundle_offline",
1439 "requires_http_server": false,
1440 "command": shell_command(&engine_replay_argv),
1441 "argv": engine_replay_argv
1442 },
1443 "sanitized": true
1444 }),
1445 ),
1446 ];
1447 for (name, value) in files {
1448 write_json_value(&bundle_dir.join(name), &value)?;
1449 }
1450 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1451 .map_err(|err| err.to_string())?;
1452 Ok(())
1453}
1454
1455fn write_chat_request_failure_diagnostics(
1456 state: &AppState,
1457 request_id: &str,
1458 failure_kind: &str,
1459 phase: &str,
1460 error_kind: &str,
1461 message: &str,
1462 engine_status: Option<&EngineStatus>,
1463) -> std::result::Result<(), String> {
1464 let admission_summary = state
1465 .auto_config
1466 .as_ref()
1467 .map(|config| config.admission_summary_document());
1468 write_chat_request_failure_diagnostics_at_root(
1469 state.request_dump_dir.as_ref().map(|root| root.as_path()),
1470 admission_summary.as_ref(),
1471 engine_status,
1472 request_id,
1473 failure_kind,
1474 phase,
1475 error_kind,
1476 message,
1477 )
1478}
1479
1480fn write_chat_request_completion_replay_bundle(
1481 request_dump_dir: Option<&Path>,
1482 request_id: &str,
1483 output_text: &str,
1484 output_token_ids: &[TokenId],
1485 finish_reason: Option<&str>,
1486) -> std::result::Result<(), String> {
1487 let Some(root) = request_dump_dir else {
1488 return Ok(());
1489 };
1490 let bundle_dir = root.join(request_id);
1491 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1492 let token_ids = output_token_ids
1493 .iter()
1494 .map(|token| token.get())
1495 .collect::<Vec<_>>();
1496 let output_text_body = format!(
1497 "[redacted actual output]\nsha256={}\nchars={}\n",
1498 sha256_hex(output_text.as_bytes()),
1499 output_text.chars().count()
1500 );
1501 write_json_value(
1502 &bundle_dir.join("output_token_ids.json"),
1503 &serde_json::json!({
1504 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1505 "request_id": request_id,
1506 "token_ids": token_ids,
1507 "token_count": output_token_ids.len(),
1508 "finish_reason": finish_reason,
1509 "unavailable_reason": null
1510 }),
1511 )?;
1512 write_json_value(
1513 &bundle_dir.join("bad_output_scan.json"),
1514 &bad_output_scan_json(request_id, output_text, None, output_text_body.as_bytes()),
1515 )?;
1516 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1517 .map_err(|err| err.to_string())?;
1518 Ok(())
1519}
1520
1521fn write_chat_prompt_token_evidence(
1522 request_dump_dir: Option<&Path>,
1523 request_id: &str,
1524 model: &str,
1525 execution_evidence: Option<&InferenceExecutionEvidence>,
1526) -> std::result::Result<(), String> {
1527 let (Some(root), Some(evidence)) = (request_dump_dir, execution_evidence) else {
1528 return Ok(());
1529 };
1530 let bundle_dir = root.join(request_id);
1531 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1532 let prompt_token_ids = evidence
1533 .prompt_token_ids
1534 .iter()
1535 .map(|token| token.get())
1536 .collect::<Vec<_>>();
1537 write_json_value(
1538 &bundle_dir.join("prompt_token_ids.json"),
1539 &serde_json::json!({
1540 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1541 "request_id": request_id,
1542 "model": model,
1543 "tokenizer_or_model": model,
1544 "token_ids": prompt_token_ids,
1545 "token_count": evidence.prompt_token_ids.len(),
1546 "unavailable_reason": null,
1547 "sanitized": true
1548 }),
1549 )
1550}
1551
1552#[derive(Clone, Copy, Default)]
1553struct ChatRequestProfileTiming<'a> {
1554 engine_evidence: Option<&'a InferenceExecutionEvidence>,
1555 first_engine_chunk_received_us: Option<u64>,
1556 first_sse_enqueue_us: Option<u64>,
1557}
1558
1559#[allow(clippy::too_many_arguments)]
1560fn write_chat_request_profile_event(
1561 state: &AppState,
1562 request_id: &str,
1563 benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1564 model: &str,
1565 stream: bool,
1566 phase: &str,
1567 started_at: Instant,
1568 timing: ChatRequestProfileTiming<'_>,
1569 output_token_count: usize,
1570 usage: Option<&TokenUsage>,
1571 finish_reason: Option<&str>,
1572 error: Option<ProfileError>,
1573) -> std::result::Result<(), String> {
1574 let Some(path) = state.profile_jsonl.as_ref() else {
1575 return Ok(());
1576 };
1577 let timestamp = chrono::Utc::now();
1578 let status = if error.is_some() {
1579 ProfileStatus::Failure
1580 } else {
1581 ProfileStatus::Ok
1582 };
1583 let duration_us = elapsed_us_since(started_at);
1584 let mut attributes = BTreeMap::from([
1585 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1586 (
1587 "diagnostic_only".to_string(),
1588 serde_json::json!(state.profile_detail.diagnostic_only()),
1589 ),
1590 (
1591 "endpoint".to_string(),
1592 serde_json::json!("/v1/chat/completions"),
1593 ),
1594 (
1595 "e2e_duration_us".to_string(),
1596 serde_json::json!(duration_us),
1597 ),
1598 ("l0_only".to_string(), serde_json::json!(false)),
1599 (
1600 "profile_detail".to_string(),
1601 serde_json::json!(state.profile_detail.as_str()),
1602 ),
1603 ("stream".to_string(), serde_json::json!(stream)),
1604 (
1605 "output_token_count".to_string(),
1606 serde_json::json!(output_token_count),
1607 ),
1608 (
1609 "execution_request_id".to_string(),
1610 serde_json::json!(format!("request.product.{request_id}")),
1611 ),
1612 ]);
1613 extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1614 if let Some(usage) = usage {
1615 attributes.insert(
1616 "prompt_token_count".to_string(),
1617 serde_json::json!(usage.prompt_tokens),
1618 );
1619 attributes.insert(
1620 "completion_token_count".to_string(),
1621 serde_json::json!(usage.completion_tokens),
1622 );
1623 attributes.insert(
1624 "total_token_count".to_string(),
1625 serde_json::json!(usage.total_tokens),
1626 );
1627 attributes.insert("token_count_source".to_string(), serde_json::json!("usage"));
1628 } else {
1629 attributes.insert(
1630 "completion_token_count".to_string(),
1631 serde_json::json!(output_token_count),
1632 );
1633 attributes.insert(
1634 "total_token_count".to_string(),
1635 serde_json::json!(output_token_count),
1636 );
1637 attributes.insert(
1638 "token_count_source".to_string(),
1639 serde_json::json!("generated_tokens"),
1640 );
1641 }
1642 if let Some(engine_timing) = timing
1643 .engine_evidence
1644 .and_then(|evidence| evidence.engine_token_timing.as_ref())
1645 {
1646 engine_timing
1647 .validate(output_token_count)
1648 .map_err(|error| format!("invalid engine token timing evidence: {error}"))?;
1649 attributes.extend(ferrum_types::engine_token_timing_profile_attributes(
1650 engine_timing,
1651 ));
1652 } else if status == ProfileStatus::Ok && state.profile_detail.captures_engine_token_timing() {
1653 return Err(format!(
1654 "{} profile completed without required engine token timing evidence",
1655 state.profile_detail.as_str()
1656 ));
1657 }
1658 if let Some(received_us) = timing.first_engine_chunk_received_us {
1659 attributes.insert(
1660 "engine_stream_first_chunk_received_us".to_string(),
1661 serde_json::json!(received_us),
1662 );
1663 }
1664 if let Some(enqueue_us) = timing.first_sse_enqueue_us {
1665 attributes.insert(
1666 "http_first_sse_enqueue_us".to_string(),
1667 serde_json::json!(enqueue_us),
1668 );
1669 }
1670 if stream {
1671 attributes.insert(
1672 "http_stream_flush_unavailable_reason".to_string(),
1673 serde_json::json!(
1674 "socket flush completion is outside the axum handler observation boundary"
1675 ),
1676 );
1677 }
1678 if let Some(reason) = finish_reason {
1679 attributes.insert("finish_reason".to_string(), serde_json::json!(reason));
1680 }
1681 if let Some(error) = error.as_ref() {
1682 attributes.insert(
1683 if error.blocking {
1684 "first_failure_event"
1685 } else {
1686 "terminal_failure_event"
1687 }
1688 .to_string(),
1689 serde_json::json!(true),
1690 );
1691 }
1692
1693 let replay = state.request_dump_dir.as_ref().map(|root| {
1694 let bundle_dir = root.join(request_id);
1695 ReplayReference {
1696 command: replay_curl_command(&bundle_dir),
1697 bundle_dir: Some(root.to_string_lossy().to_string()),
1698 }
1699 });
1700 let resource = error.as_ref().map(|error| ResourceTraceEvent {
1701 owner_kind: "request".to_string(),
1702 owner_id: request_id.to_string(),
1703 resource_kind: "chat_request".to_string(),
1704 action: ResourceAction::Reject,
1705 amount: None,
1706 before: None,
1707 after: None,
1708 capacity: Some(1),
1709 underflow_amount: None,
1710 reason: Some(error.message.clone()),
1711 error_kind: Some(error.kind.clone()),
1712 message: Some(error.message.clone()),
1713 resource_error_kind: Some(error.kind.clone()),
1714 });
1715 let event = FerrumProfileEvent {
1716 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1717 ts_unix_nanos: timestamp
1718 .timestamp_nanos_opt()
1719 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1720 event_id: format!(
1721 "evt-server-chat-{}-{request_id}",
1722 if stream { "stream" } else { "sync" }
1723 ),
1724 request_id: request_id.to_string(),
1725 correlation_id: Some(request_id.to_string()),
1726 entrypoint: ProfileEntrypoint::Serve,
1727 backend: "actual".to_string(),
1728 runtime_preset_hash: state
1729 .auto_config
1730 .as_ref()
1731 .map(ResolvedFerrumConfig::runtime_env_hash)
1732 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1733 phase: phase.to_string(),
1734 event_kind: ProfileEventKind::TimedSpan,
1735 timestamp,
1736 status,
1737 model: Some(model.to_string()),
1738 duration_us: Some(duration_us),
1739 memory: None,
1740 resource,
1741 error,
1742 replay,
1743 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1744 backend_detail: None,
1745 attributes,
1746 };
1747 append_profile_event(path.as_path(), &event)
1748}
1749
1750fn maybe_write_first_request_memory_stage(
1751 state: &AppState,
1752 request_id: &str,
1753 benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1754 model: &str,
1755 stream: bool,
1756 started_at: Instant,
1757 before: Option<ProcessMemorySample>,
1758) -> std::result::Result<(), String> {
1759 if state.profile_jsonl.is_none() && state.memory_profile_jsonl.is_none() {
1760 return Ok(());
1761 }
1762 if state
1763 .first_request_memory_recorded
1764 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1765 .is_err()
1766 {
1767 return Ok(());
1768 }
1769 let after = ProcessMemorySampler.sample();
1770 let memory = after.map(|after| ProcessMemoryObservation::from_samples(before, after));
1771 let timestamp = chrono::Utc::now();
1772 let mut attributes = BTreeMap::from([
1773 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1774 (
1775 "diagnostic_only".to_string(),
1776 serde_json::json!(state.profile_detail.diagnostic_only()),
1777 ),
1778 (
1779 "endpoint".to_string(),
1780 serde_json::json!("/v1/chat/completions"),
1781 ),
1782 ("l0_only".to_string(), serde_json::json!(false)),
1783 (
1784 "memory_stage".to_string(),
1785 serde_json::json!("first_request_done"),
1786 ),
1787 (
1788 "profile_detail".to_string(),
1789 serde_json::json!(state.profile_detail.as_str()),
1790 ),
1791 ("stream".to_string(), serde_json::json!(stream)),
1792 ]);
1793 extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1794 let memory_snapshot = if let Some(memory) = &memory {
1795 attributes.insert(
1796 "memory_measurement".to_string(),
1797 serde_json::json!("process_rss"),
1798 );
1799 attributes.insert(
1800 "process_memory_source".to_string(),
1801 serde_json::json!(memory.source),
1802 );
1803 memory.to_snapshot("process", Some("actual"))
1804 } else {
1805 attributes.insert(
1806 "memory_measurement".to_string(),
1807 serde_json::json!("not_collected"),
1808 );
1809 ferrum_types::MemorySnapshot {
1810 scope: "process".to_string(),
1811 backend: Some("actual".to_string()),
1812 before_bytes: Some(0),
1813 after_bytes: Some(0),
1814 current_bytes: Some(0),
1815 high_water_bytes: Some(0),
1816 available_bytes: None,
1817 }
1818 };
1819 let replay = state.request_dump_dir.as_ref().map(|root| {
1820 let bundle_dir = root.join(request_id);
1821 ReplayReference {
1822 command: replay_curl_command(&bundle_dir),
1823 bundle_dir: Some(root.to_string_lossy().to_string()),
1824 }
1825 });
1826 let event = FerrumProfileEvent {
1827 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1828 ts_unix_nanos: timestamp
1829 .timestamp_nanos_opt()
1830 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1831 event_id: format!("evt-server-chat-memory-first-request-{request_id}"),
1832 request_id: request_id.to_string(),
1833 correlation_id: Some(request_id.to_string()),
1834 entrypoint: ProfileEntrypoint::Serve,
1835 backend: "actual".to_string(),
1836 runtime_preset_hash: state
1837 .auto_config
1838 .as_ref()
1839 .map(ResolvedFerrumConfig::runtime_env_hash)
1840 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1841 phase: "actual_serve_first_request_done".to_string(),
1842 event_kind: ProfileEventKind::Memory,
1843 timestamp,
1844 status: ProfileStatus::Ok,
1845 model: Some(model.to_string()),
1846 duration_us: Some(elapsed_us_since(started_at)),
1847 memory: Some(memory_snapshot),
1848 resource: None,
1849 error: None,
1850 replay,
1851 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1852 backend_detail: None,
1853 attributes,
1854 };
1855 if let Some(path) = &state.profile_jsonl {
1856 append_profile_event(path.as_path(), &event)?;
1857 }
1858 if let Some(path) = &state.memory_profile_jsonl {
1859 append_profile_event(path.as_path(), &event)?;
1860 }
1861 Ok(())
1862}
1863
1864fn request_memory_sample_before(state: &AppState) -> Option<ProcessMemorySample> {
1865 (state.profile_jsonl.is_some() || state.memory_profile_jsonl.is_some())
1866 .then(|| ProcessMemorySampler.sample())
1867 .flatten()
1868}
1869
1870fn append_profile_event(
1871 path: &Path,
1872 event: &FerrumProfileEvent,
1873) -> std::result::Result<(), String> {
1874 event.validate().map_err(|err| err.to_string())?;
1875 ferrum_bench_core::write_jsonl_records(
1876 path,
1877 ferrum_bench_core::JsonlJournalOpenMode::Append,
1878 std::slice::from_ref(event),
1879 )
1880 .map_err(|error| error.to_string())
1881}
1882
1883fn elapsed_us_since(started_at: Instant) -> u64 {
1884 started_at
1885 .elapsed()
1886 .as_micros()
1887 .max(1)
1888 .try_into()
1889 .unwrap_or(u64::MAX)
1890}
1891
1892fn write_chat_request_failure_diagnostics_at_root(
1893 request_dump_dir: Option<&Path>,
1894 admission_summary: Option<&serde_json::Value>,
1895 engine_status: Option<&EngineStatus>,
1896 request_id: &str,
1897 failure_kind: &str,
1898 phase: &str,
1899 error_kind: &str,
1900 message: &str,
1901) -> std::result::Result<(), String> {
1902 let Some(root) = request_dump_dir else {
1903 return Ok(());
1904 };
1905 let bundle_dir = root.join(request_id);
1906 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1907 let message = sanitize_diagnostic_text(message);
1908 let now = chrono::Utc::now();
1909
1910 let bad_scan_path = bundle_dir.join("bad_output_scan.json");
1911 let mut bad_scan = fs::read_to_string(&bad_scan_path)
1912 .ok()
1913 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
1914 .filter(|value| value.is_object())
1915 .unwrap_or_else(|| serde_json::json!({}));
1916 let bad_scan_obj = bad_scan
1917 .as_object_mut()
1918 .expect("bad scan fallback should be an object");
1919 bad_scan_obj.insert(
1920 "schema_version".to_string(),
1921 serde_json::json!(OBSERVABILITY_PROFILE_SCHEMA_VERSION),
1922 );
1923 bad_scan_obj.insert("request_id".to_string(), serde_json::json!(request_id));
1924 bad_scan_obj
1925 .entry("bad_output".to_string())
1926 .or_insert_with(|| serde_json::json!(false));
1927 bad_scan_obj
1928 .entry("bad_text_count".to_string())
1929 .or_insert_with(|| serde_json::json!(0));
1930 bad_scan_obj
1931 .entry("reasons".to_string())
1932 .or_insert_with(|| serde_json::json!([]));
1933 bad_scan_obj
1934 .entry("first_bad_text_span".to_string())
1935 .or_insert(serde_json::Value::Null);
1936 bad_scan_obj.insert("failure_kind".to_string(), serde_json::json!(failure_kind));
1937 bad_scan_obj.insert("failure_phase".to_string(), serde_json::json!(phase));
1938 bad_scan_obj.insert("error_kind".to_string(), serde_json::json!(error_kind));
1939 bad_scan_obj
1940 .entry("output_chars".to_string())
1941 .or_insert_with(|| serde_json::json!(0));
1942 bad_scan_obj
1943 .entry("output_sha256".to_string())
1944 .or_insert_with(|| serde_json::json!(sha256_hex(b"")));
1945 write_json_value(&bad_scan_path, &bad_scan)?;
1946
1947 let diagnostics = if chat_resource_failure_kind(failure_kind) {
1948 chat_resource_failure_diagnostics(
1949 request_id,
1950 failure_kind,
1951 phase,
1952 error_kind,
1953 &message,
1954 now.timestamp_millis(),
1955 admission_summary,
1956 engine_status,
1957 )
1958 } else {
1959 serde_json::json!({
1960 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1961 "entrypoint": "serve",
1962 "request_id": request_id,
1963 "failure_kind": failure_kind,
1964 "phase": phase,
1965 "first_failure_event": {
1966 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1967 "entrypoint": "serve",
1968 "request_id": request_id,
1969 "phase": phase,
1970 "error_kind": error_kind,
1971 "message": message,
1972 "timestamp_unix_ms": now.timestamp_millis()
1973 },
1974 "nearest_request_id": request_id,
1975 "log_excerpt": format!("{phase}: {message}"),
1976 "backtrace_excerpt": null,
1977 "nearest_resource_event": null,
1978 "nearest_memory_snapshot": null
1979 })
1980 };
1981 write_json_value(&bundle_dir.join("failure_diagnostics.json"), &diagnostics)?;
1982 Ok(())
1983}
1984
1985fn chat_resource_failure_diagnostics(
1986 request_id: &str,
1987 failure_kind: &str,
1988 phase: &str,
1989 error_kind: &str,
1990 message: &str,
1991 timestamp_unix_ms: i64,
1992 admission_summary: Option<&serde_json::Value>,
1993 engine_status: Option<&EngineStatus>,
1994) -> serde_json::Value {
1995 let resource_kind = chat_resource_kind_for_failure(failure_kind);
1996 let memory = engine_status
1997 .map(|status| &status.memory_usage)
1998 .map(|memory| {
1999 let current = memory.used_bytes as i64;
2000 let high_water = current.max(0);
2001 serde_json::json!({
2002 "scope": "serve_failure",
2003 "backend": "engine_status",
2004 "current_bytes": current.max(0),
2005 "high_water_bytes": high_water,
2006 "total_bytes": memory.total_bytes,
2007 "free_bytes": memory.free_bytes,
2008 "gpu_memory_bytes": memory.gpu_memory_bytes,
2009 "cpu_memory_bytes": memory.cpu_memory_bytes,
2010 "source": "engine_status"
2011 })
2012 })
2013 .unwrap_or_else(|| {
2014 serde_json::json!({
2015 "scope": "serve_failure",
2016 "backend": "engine_status",
2017 "current_bytes": 0,
2018 "high_water_bytes": 0,
2019 "source": "not_collected"
2020 })
2021 });
2022 let capacity = chat_failure_capacity(resource_kind, admission_summary, engine_status, message);
2023 let needed = capacity
2024 .get("needed")
2025 .and_then(|value| value.as_i64())
2026 .unwrap_or(1)
2027 .max(1);
2028 let capacity_value = capacity
2029 .get("capacity")
2030 .and_then(|value| value.as_i64())
2031 .unwrap_or(0)
2032 .max(0);
2033 serde_json::json!({
2034 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2035 "entrypoint": "serve",
2036 "request_id": request_id,
2037 "failure_kind": failure_kind,
2038 "phase": phase,
2039 "first_failure_event": {
2040 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2041 "entrypoint": "serve",
2042 "request_id": request_id,
2043 "phase": phase,
2044 "error_kind": error_kind,
2045 "message": message,
2046 "timestamp_unix_ms": timestamp_unix_ms
2047 },
2048 "nearest_request_id": request_id,
2049 "log_excerpt": format!("{phase}: {message}"),
2050 "capacity": capacity,
2051 "nearest_resource_event": {
2052 "owner_kind": "request",
2053 "owner_id": request_id,
2054 "resource_kind": resource_kind,
2055 "action": "reject",
2056 "amount": needed,
2057 "before": capacity_value,
2058 "after": capacity_value,
2059 "capacity": capacity_value,
2060 "reason": message
2061 },
2062 "nearest_memory_snapshot": memory
2063 })
2064}
2065
2066fn chat_failure_capacity(
2067 resource_kind: &str,
2068 admission_summary: Option<&serde_json::Value>,
2069 engine_status: Option<&EngineStatus>,
2070 reason: &str,
2071) -> serde_json::Value {
2072 if resource_kind == "device_memory" {
2073 let (needed, available, capacity) = engine_status
2074 .map(|status| {
2075 let memory = &status.memory_usage;
2076 let used = memory.used_bytes as i64;
2077 let available = memory.free_bytes as i64;
2078 let capacity = memory.total_bytes as i64;
2079 (
2080 used.saturating_add(1).max(1),
2081 available.max(0),
2082 capacity.max(0),
2083 )
2084 })
2085 .unwrap_or((1, 0, 0));
2086 return serde_json::json!({
2087 "resource_kind": resource_kind,
2088 "needed": needed,
2089 "available": available,
2090 "capacity": capacity,
2091 "reason": reason
2092 });
2093 }
2094 let capacity = admission_summary
2095 .and_then(|summary| summary.get("effective_max_concurrent"))
2096 .and_then(|value| {
2097 value
2098 .as_i64()
2099 .or_else(|| value.as_u64().map(|value| value as i64))
2100 })
2101 .unwrap_or_else(|| {
2102 engine_status
2103 .map(|status| {
2104 (status.active_requests as i64)
2105 .saturating_add(status.queued_requests as i64)
2106 .saturating_add(1)
2107 })
2108 .unwrap_or(0)
2109 })
2110 .max(0);
2111 let used = engine_status
2112 .map(|status| (status.active_requests as i64).saturating_add(status.queued_requests as i64))
2113 .unwrap_or(0)
2114 .max(0);
2115 serde_json::json!({
2116 "resource_kind": resource_kind,
2117 "needed": 1,
2118 "available": capacity.saturating_sub(used),
2119 "capacity": capacity,
2120 "reason": reason
2121 })
2122}
2123
2124fn chat_resource_failure_kind(failure_kind: &str) -> bool {
2125 matches!(
2126 failure_kind,
2127 "oom" | "prevented_oom" | "admission" | "admission_reject" | "oom_admission"
2128 )
2129}
2130
2131fn chat_resource_kind_for_failure(failure_kind: &str) -> &'static str {
2132 match failure_kind {
2133 "oom" | "prevented_oom" => "device_memory",
2134 _ => "admission_capacity",
2135 }
2136}
2137
2138fn sanitize_diagnostic_text(message: &str) -> String {
2139 let trimmed = message.trim();
2140 if trimmed.is_empty() {
2141 return "generation failed without an error message".to_string();
2142 }
2143 let lower = trimmed.to_ascii_lowercase();
2144 if lower.contains("authorization")
2145 || lower.contains("cookie")
2146 || lower.contains("api_key")
2147 || lower.contains("access_token")
2148 || lower.contains("refresh_token")
2149 || lower.contains("password")
2150 || trimmed.contains("sk-")
2151 {
2152 return "[redacted diagnostic message]".to_string();
2153 }
2154 trimmed.chars().take(2048).collect()
2155}
2156
2157fn sanitized_replay_headers(headers: &HeaderMap) -> serde_json::Value {
2158 let mut result = serde_json::Map::new();
2159 for key in ["content-type", "traceparent", "tracestate"] {
2160 if let Some(value) = headers.get(key).and_then(|value| value.to_str().ok()) {
2161 result.insert(key.to_string(), serde_json::json!(value));
2162 }
2163 }
2164 result.insert("authorization".to_string(), serde_json::json!("[redacted]"));
2165 result.insert("cookie".to_string(), serde_json::json!("[redacted]"));
2166 serde_json::Value::Object(result)
2167}
2168
2169fn sanitized_chat_request_body(request: &ChatCompletionsRequest) -> serde_json::Value {
2170 let mut value = serde_json::to_value(request).unwrap_or_else(|_| {
2171 serde_json::json!({
2172 "model": request.model.clone(),
2173 "stream": request.stream.unwrap_or(false),
2174 "messages": []
2175 })
2176 });
2177 redact_json_value(&mut value, None);
2178 value
2179}
2180
2181fn redact_json_value(value: &mut serde_json::Value, key: Option<&str>) {
2182 if key.is_some_and(is_secret_key) {
2183 *value = serde_json::json!("[redacted]");
2184 return;
2185 }
2186 if matches!(key, Some("content" | "arguments")) && value.is_string() {
2187 *value = serde_json::json!("[redacted]");
2188 return;
2189 }
2190 match value {
2191 serde_json::Value::Object(map) => {
2192 for field in ["content", "arguments"] {
2193 if let Some(chars) = map
2194 .get(field)
2195 .and_then(|child| child.as_str())
2196 .map(|text| text.chars().count())
2197 {
2198 map.insert(field.to_string(), serde_json::json!("[redacted]"));
2199 map.insert(format!("{field}_redacted"), serde_json::json!(true));
2200 map.insert(format!("{field}_chars"), serde_json::json!(chars));
2201 }
2202 }
2203 for (child_key, child) in map.iter_mut() {
2204 redact_json_value(child, Some(child_key.as_str()));
2205 }
2206 }
2207 serde_json::Value::Array(items) => {
2208 for child in items {
2209 redact_json_value(child, None);
2210 }
2211 }
2212 _ => {}
2213 }
2214}
2215
2216fn is_secret_key(key: &str) -> bool {
2217 let normalized = key
2218 .chars()
2219 .filter(|ch| *ch != '-' && *ch != '_')
2220 .flat_map(char::to_lowercase)
2221 .collect::<String>();
2222 matches!(
2223 normalized.as_str(),
2224 "authorization"
2225 | "cookie"
2226 | "secret"
2227 | "apikey"
2228 | "password"
2229 | "accesstoken"
2230 | "refreshtoken"
2231 | "idtoken"
2232 )
2233}
2234
2235fn replay_curl_argv(bundle_dir: &Path) -> Vec<String> {
2236 vec![
2237 "curl".to_string(),
2238 "-sS".to_string(),
2239 "-X".to_string(),
2240 "POST".to_string(),
2241 "http://127.0.0.1:8000/v1/chat/completions".to_string(),
2242 "-H".to_string(),
2243 "content-type: application/json".to_string(),
2244 "--data-binary".to_string(),
2245 format!("@{}", bundle_dir.join("replay_body.json").display()),
2246 ]
2247}
2248
2249fn replay_curl_command(bundle_dir: &Path) -> String {
2250 shell_command(&replay_curl_argv(bundle_dir))
2251}
2252
2253fn replay_bundle_argv(bundle_dir: &Path) -> Vec<String> {
2254 vec![
2255 "cargo".to_string(),
2256 "run".to_string(),
2257 "-p".to_string(),
2258 "ferrum-cli".to_string(),
2259 "--".to_string(),
2260 "replay-bundle".to_string(),
2261 bundle_dir.to_string_lossy().to_string(),
2262 "--out".to_string(),
2263 bundle_dir
2264 .join("engine_replay")
2265 .to_string_lossy()
2266 .to_string(),
2267 "--json".to_string(),
2268 ]
2269}
2270
2271fn shell_command(argv: &[String]) -> String {
2272 argv.iter()
2273 .map(|part| shell_quote(part))
2274 .collect::<Vec<_>>()
2275 .join(" ")
2276}
2277
2278fn shell_quote(value: &str) -> String {
2279 if value
2280 .chars()
2281 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '@'))
2282 {
2283 value.to_string()
2284 } else {
2285 format!("'{}'", value.replace('\'', "'\\''"))
2286 }
2287}
2288
2289fn write_json_value(path: &Path, value: &serde_json::Value) -> std::result::Result<(), String> {
2290 let bytes = serde_json::to_vec_pretty(value).map_err(|err| err.to_string())?;
2291 fs::write(path, [bytes, b"\n".to_vec()].concat()).map_err(|err| err.to_string())
2292}
2293
2294fn bad_output_scan_json(
2295 request_id: &str,
2296 text: &str,
2297 failure_kind: Option<&str>,
2298 output_artifact_bytes: &[u8],
2299) -> serde_json::Value {
2300 let mut reasons = Vec::new();
2301 let mut first_span: Option<serde_json::Value> = None;
2302 for (needle, reason) in [
2303 ("<unk>", "reserved_token"),
2304 ("[PAD", "reserved_token"),
2305 ("<pad>", "reserved_token"),
2306 ("<|endoftext|>", "reserved_token"),
2307 ("<|im_start|>", "reserved_token"),
2308 ("<|im_end|>", "reserved_token"),
2309 ("<|reserved_special_token", "reserved_token"),
2310 ("\u{fffd}", "invalid_utf8"),
2311 ] {
2312 if let Some(index) = text.find(needle) {
2313 reasons.push(reason);
2314 first_span.get_or_insert_with(|| {
2315 serde_json::json!({
2316 "byte_start": index,
2317 "byte_end": index + needle.len(),
2318 "text": needle,
2319 "reason": reason
2320 })
2321 });
2322 }
2323 }
2324 if let Some(index) = first_mojibake_index(text) {
2325 reasons.push("mojibake");
2326 first_span.get_or_insert_with(|| {
2327 serde_json::json!({
2328 "byte_start": index,
2329 "byte_end": index + 1,
2330 "reason": "mojibake"
2331 })
2332 });
2333 }
2334 reasons.sort_unstable();
2335 reasons.dedup();
2336 let bad_output = !reasons.is_empty();
2337 serde_json::json!({
2338 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2339 "request_id": request_id,
2340 "bad_output": bad_output,
2341 "bad_text_count": if bad_output { 1 } else { 0 },
2342 "reasons": reasons,
2343 "first_bad_text_span": first_span,
2344 "failure_kind": failure_kind,
2345 "output_chars": text.chars().count(),
2346 "classified_output_sha256": sha256_hex(text.as_bytes()),
2347 "output_sha256": sha256_hex(output_artifact_bytes)
2348 })
2349}
2350
2351fn first_mojibake_index(text: &str) -> Option<usize> {
2352 let mut chars = text.char_indices().peekable();
2353 while let Some((index, ch)) = chars.next() {
2354 match ch {
2355 '\u{00c2}' | '\u{00c3}' => {
2356 if chars.peek().is_some_and(|(_, next)| !next.is_ascii()) {
2357 return Some(index);
2358 }
2359 }
2360 '\u{00e2}' => {
2361 if chars.peek().is_some_and(|(_, next)| *next == '\u{20ac}') {
2362 return Some(index);
2363 }
2364 }
2365 _ => {}
2366 }
2367 }
2368 None
2369}
2370
2371fn sha256_hex(bytes: &[u8]) -> String {
2372 let mut hasher = Sha256::new();
2373 hasher.update(bytes);
2374 format!("{:x}", hasher.finalize())
2375}
2376
2377struct ParsedChatModelOutput {
2378 visible: ParsedReasoningResponse,
2379 harmony_response: Option<ferrum_types::ApiChatResponse>,
2380}
2381
2382fn parse_chat_model_output(
2383 protocol: ModelOutputProtocol,
2384 text: &str,
2385 started_in_think: bool,
2386 finish_reason: FinishReason,
2387) -> std::result::Result<ParsedChatModelOutput, ServerError> {
2388 match protocol {
2389 ModelOutputProtocol::Text | ModelOutputProtocol::GemmaThought => {
2390 Ok(ParsedChatModelOutput {
2391 visible: parse_model_reasoning_response(protocol, text, started_in_think)
2392 .map_err(|error| ServerError::InternalError(error.to_string()))?,
2393 harmony_response: None,
2394 })
2395 }
2396 ModelOutputProtocol::HarmonyGptOss => {
2397 let parsed = parse_harmony_response_for_finish_reason(text, Some(finish_reason))
2398 .map_err(|error| {
2399 ServerError::InternalError(format!(
2400 "model output did not satisfy the GPT-OSS Harmony protocol: {error}"
2401 ))
2402 })?;
2403 let harmony_response =
2404 parsed
2405 .tool_call
2406 .map(|tool_call| ferrum_types::ApiChatResponse {
2407 message: ferrum_types::ApiChatMessage {
2408 role: ferrum_types::ApiMessageRole::Assistant,
2409 content: String::new(),
2410 name: None,
2411 tool_calls: vec![ferrum_types::ApiToolCall {
2412 id: format!("call_{}", Uuid::new_v4().simple()),
2413 tool_type: "function".to_string(),
2414 function: ferrum_types::ApiFunctionCall {
2415 name: tool_call.name,
2416 arguments: tool_call.arguments_json,
2417 },
2418 }],
2419 tool_call_id: None,
2420 function_call: None,
2421 },
2422 finish_reason: Some("tool_calls".to_string()),
2423 });
2424 Ok(ParsedChatModelOutput {
2425 visible: ParsedReasoningResponse {
2426 content: parsed.content,
2427 reasoning: parsed.reasoning_content,
2428 },
2429 harmony_response,
2430 })
2431 }
2432 }
2433}
2434
2435async fn handle_chat_completions_stream(
2437 state: AppState,
2438 openai_request: ChatCompletionsRequest,
2439 inference_request: InferenceRequest,
2440 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
2441) -> std::result::Result<Response, ServerError> {
2442 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2443
2444 let engine = state.llm.clone().ok_or_else(|| {
2446 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2447 })?;
2448 let request_id = inference_request.id.to_string();
2449 let include_stream_usage = openai_request
2450 .stream_options
2451 .as_ref()
2452 .and_then(|opts| opts.include_usage)
2453 .unwrap_or(false);
2454 let output_contract = EffectiveChatOutputContract::resolve(&openai_request);
2455 let buffer_json_object_stream = matches!(
2456 output_contract,
2457 EffectiveChatOutputContract::JsonObjectContent
2458 );
2459 let buffer_strict_json_schema_stream = matches!(
2460 output_contract,
2461 EffectiveChatOutputContract::StrictJsonSchemaContent
2462 );
2463 let stream_api_request = match inference_request.api_request.as_ref() {
2464 Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
2465 _ => api_chat_request(
2466 &openai_request,
2467 openai_request.tool_choice.as_ref(),
2468 ferrum_types::ApiToolCallProtocol::default(),
2469 ),
2470 };
2471 let buffer_structured_api_stream =
2472 ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
2473 let model_output_protocol = inference_request.sampling_params.model_output_protocol;
2474 let buffer_stream_output = buffer_json_object_stream
2475 || buffer_strict_json_schema_stream
2476 || buffer_structured_api_stream
2477 || model_output_protocol == ModelOutputProtocol::HarmonyGptOss;
2478 let started_in_think = request_started_in_reasoning(&inference_request);
2481 let mut native_projector = NativeChatOutputProjector::for_request(&inference_request);
2482 let replay_request_id = inference_request.id.to_string();
2483 let profile_request_model = openai_request.model.clone();
2484 let profile_started_at = Instant::now();
2485 let request_memory_before = request_memory_sample_before(&state);
2486 let mut stream = match engine.infer_stream(inference_request).await {
2487 Ok(stream) => stream,
2488 Err(e) => {
2489 let failure_kind = e.observability_failure_kind();
2490 let error_kind = e.observability_error_kind();
2491 let error_message = e.to_string();
2492 if let Err(err) = write_chat_request_profile_event(
2493 &state,
2494 &replay_request_id,
2495 benchmark_correlation.as_ref(),
2496 &profile_request_model,
2497 true,
2498 "chat_completions_stream_start",
2499 profile_started_at,
2500 ChatRequestProfileTiming::default(),
2501 0,
2502 None,
2503 Some("error"),
2504 Some(ProfileError {
2505 kind: error_kind.to_string(),
2506 message: error_message.clone(),
2507 blocking: false,
2508 }),
2509 ) {
2510 warn!("failed to write chat stream failure profile event: {}", err);
2511 }
2512 let engine_status = if chat_resource_failure_kind(failure_kind) {
2513 Some(engine.status().await)
2514 } else {
2515 None
2516 };
2517 error!(
2518 "Stream generation failed before first chunk: {}",
2519 error_message
2520 );
2521 if let Err(err) = write_chat_request_failure_diagnostics(
2522 &state,
2523 &replay_request_id,
2524 failure_kind,
2525 "chat_completions_stream_start",
2526 error_kind,
2527 &error_message,
2528 engine_status.as_ref(),
2529 ) {
2530 warn!("failed to write chat stream failure diagnostics: {}", err);
2531 }
2532 return Err(server_error_from_ferrum_error(e));
2533 }
2534 };
2535 let request_dump_dir = state.request_dump_dir.clone();
2536 let admission_summary = state
2537 .auto_config
2538 .as_ref()
2539 .map(|config| config.admission_summary_document());
2540 let diagnostics_engine = engine.clone();
2541 let profile_state = state.clone();
2542
2543 tokio::spawn(async move {
2544 let mut current_text = String::new();
2545 let mut output_token_ids = Vec::new();
2546 let mut first_engine_chunk_received_us = None;
2547 let mut first_sse_enqueue_us = None;
2548 let mut sent_reasoning_len = 0usize;
2549 let mut sent_content_len = 0usize;
2550
2551 loop {
2552 let next = tokio::select! {
2553 biased;
2554 _ = tx.closed() => break,
2555 next = stream.next() => next,
2556 };
2557 let Some(result) = next else {
2558 break;
2559 };
2560 match result {
2561 Ok(chunk) => {
2562 if first_engine_chunk_received_us.is_none()
2563 && (chunk.token.is_some() || !chunk.text.is_empty())
2564 {
2565 first_engine_chunk_received_us = Some(elapsed_us_since(profile_started_at));
2566 }
2567 if let Some(token) = chunk.token {
2568 output_token_ids.push(token);
2569 }
2570 if !chunk.text.is_empty() {
2571 current_text.push_str(&chunk.text);
2572 if let Some(projector) = native_projector.as_mut() {
2573 projector.push(&chunk.text);
2574 }
2575
2576 if native_projector.is_some()
2577 || (!buffer_stream_output
2578 && !should_defer_model_reasoning_stream_delta(
2579 model_output_protocol,
2580 ¤t_text,
2581 ))
2582 {
2583 let parsed_result = if let Some(projector) = native_projector.as_ref() {
2584 Ok(ParsedReasoningResponse {
2585 content: projector.visible_prefix().to_owned(),
2586 reasoning: projector.reasoning_prefix().map(str::to_owned),
2587 })
2588 } else {
2589 parse_model_reasoning_response(
2590 model_output_protocol,
2591 ¤t_text,
2592 started_in_think,
2593 )
2594 };
2595 let parsed = match parsed_result {
2596 Ok(parsed) => parsed,
2597 Err(error) => {
2598 let _ = tx.send(Ok(openai_error_sse_event(
2599 error.to_string(),
2600 "internal_server_error",
2601 Some("model_output"),
2602 )));
2603 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2604 break;
2605 }
2606 };
2607 let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
2608 let reasoning_delta =
2609 stream_text_delta(full_reasoning, &mut sent_reasoning_len);
2610 let content_delta =
2611 stream_text_delta(&parsed.content, &mut sent_content_len);
2612 if !reasoning_delta.is_empty() || !content_delta.is_empty() {
2613 let response_chunk = ChatCompletionsResponse {
2615 id: request_id.clone(),
2616 object: "chat.completion.chunk".to_string(),
2617 created: chrono::Utc::now().timestamp() as u64,
2618 model: openai_request.model.clone(),
2619 choices: vec![ChatChoice {
2620 index: 0,
2621 message: None,
2622 delta: Some(ChatMessage {
2623 role: MessageRole::Assistant,
2624 content: content_delta,
2625 reasoning: (!reasoning_delta.is_empty())
2626 .then_some(reasoning_delta),
2627 name: None,
2628 tool_calls: None,
2629 tool_call_id: None,
2630 function_call: None,
2631 }),
2632 finish_reason: None,
2633 }],
2634 usage: None,
2635 };
2636
2637 let sse_event = Event::default()
2638 .json_data(&response_chunk)
2639 .unwrap_or_else(|_| Event::default().data("error"));
2640 if tx.send(Ok(sse_event)).is_err() {
2641 break;
2642 }
2643 first_sse_enqueue_us
2644 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2645 }
2646 }
2647 }
2648
2649 if chunk.finish_reason.is_some() {
2650 let terminal_finish_reason = chunk
2651 .finish_reason
2652 .expect("finish_reason presence checked above");
2653 if let Err(err) = write_chat_prompt_token_evidence(
2654 request_dump_dir.as_ref().map(|root| root.as_path()),
2655 &replay_request_id,
2656 &profile_request_model,
2657 chunk.execution_evidence.as_ref(),
2658 ) {
2659 warn!("failed to write chat stream prompt-token evidence: {}", err);
2660 }
2661 let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
2662 let native_projected = native_projector
2663 .take()
2664 .map(|projector| projector.finish(terminal_finish_reason));
2665 let parsed_output_result =
2666 if let Some(projected) = native_projected.as_ref() {
2667 Ok(ParsedChatModelOutput {
2668 visible: projected.visible.clone(),
2669 harmony_response: None,
2670 })
2671 } else {
2672 parse_chat_model_output(
2673 model_output_protocol,
2674 ¤t_text,
2675 started_in_think,
2676 terminal_finish_reason,
2677 )
2678 };
2679 let parsed_model_output = match parsed_output_result {
2680 Ok(parsed) => parsed,
2681 Err(error) => {
2682 let error_event = openai_error_sse_event(
2683 stream_validation_error_message(error),
2684 "internal_server_error",
2685 Some("model_output"),
2686 );
2687 let _ = tx.send(Ok(error_event));
2688 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2689 break;
2690 }
2691 };
2692 let mut parsed_final = parsed_model_output.visible;
2693 parsed_final.content = normalize_structured_response_content(
2694 &openai_request,
2695 &parsed_final.content,
2696 );
2697 let mut structured_chat_response =
2698 finish_reason_allows_structured_api_response(terminal_finish_reason)
2699 .then(|| match chunk.api_response.as_ref() {
2700 _ if model_output_protocol
2703 == ModelOutputProtocol::HarmonyGptOss =>
2704 {
2705 parsed_model_output.harmony_response.clone()
2706 }
2707 Some(ferrum_types::ApiResponse::Chat(response)) => {
2708 Some(response.clone())
2709 }
2710 _ if native_projected.is_some() => native_projected
2711 .as_ref()
2712 .and_then(|projected| projected.api_response.clone()),
2713 _ if buffer_structured_api_stream => {
2714 chat_api_response_from_parsed_generated_text(
2715 &stream_api_request,
2716 &parsed_final,
2717 terminal_finish_reason,
2718 )
2719 }
2720 _ => None,
2721 })
2722 .flatten();
2723
2724 if native_projected.is_none()
2725 && matches!(
2726 chunk.api_response,
2727 Some(ferrum_types::ApiResponse::Chat(_))
2728 )
2729 {
2730 if let Some(response) = structured_chat_response.as_mut() {
2731 if let Err(error) = project_typed_tool_response_content(
2732 response,
2733 model_output_protocol,
2734 started_in_think,
2735 ) {
2736 let _ = tx.send(Ok(openai_error_sse_event(
2737 stream_validation_error_message(error),
2738 "internal_server_error",
2739 Some("model_output"),
2740 )));
2741 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2742 break;
2743 }
2744 }
2745 }
2746
2747 if let Some(chat_response) = structured_chat_response.as_ref() {
2748 if let Err(e) =
2749 validate_structured_tool_response(&openai_request, chat_response)
2750 {
2751 let error_event = openai_error_sse_event(
2752 stream_validation_error_message(e),
2753 "internal_server_error",
2754 Some("tool_choice"),
2755 );
2756 let _ = tx.send(Ok(error_event));
2757 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2758 break;
2759 }
2760 } else if tool_choice_required(&openai_request) {
2761 log_required_tool_choice_failure(
2762 &openai_request,
2763 &parsed_final.content,
2764 parsed_final.reasoning.as_deref(),
2765 );
2766 let error_event = openai_error_sse_event(
2767 "model output did not satisfy required tool_choice",
2768 "invalid_request_error",
2769 Some("tool_choice"),
2770 );
2771 let _ = tx.send(Ok(error_event));
2772 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2773 break;
2774 }
2775 if let Err(e) = validate_hard_structured_response(
2776 &openai_request,
2777 &parsed_final.content,
2778 structured_chat_response.as_ref(),
2779 ) {
2780 let error_event = openai_error_sse_event(
2781 stream_validation_error_message(e),
2782 "internal_server_error",
2783 structured_response_error_param(output_contract),
2784 );
2785 let _ = tx.send(Ok(error_event));
2786 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2787 break;
2788 }
2789
2790 if let Some(chat_response) = structured_chat_response.as_ref() {
2791 let mut delta = openai_chat_delta_from_api(&chat_response.message);
2792 if native_projected.is_some() {
2793 delta.content =
2794 stream_text_delta(&delta.content, &mut sent_content_len);
2795 }
2796 if delta.reasoning.is_none() {
2797 delta.reasoning = parsed_final.reasoning.clone();
2798 }
2799 if native_projected.is_some() {
2800 let reasoning = stream_text_delta(
2801 delta.reasoning.as_deref().unwrap_or(""),
2802 &mut sent_reasoning_len,
2803 );
2804 delta.reasoning = (!reasoning.is_empty()).then_some(reasoning);
2805 }
2806 let response_chunk = ChatCompletionsResponse {
2807 id: request_id.clone(),
2808 object: "chat.completion.chunk".to_string(),
2809 created: chrono::Utc::now().timestamp() as u64,
2810 model: openai_request.model.clone(),
2811 choices: vec![ChatChoice {
2812 index: 0,
2813 message: None,
2814 delta: Some(delta),
2815 finish_reason: None,
2816 }],
2817 usage: None,
2818 };
2819
2820 let sse_event = Event::default()
2821 .json_data(&response_chunk)
2822 .unwrap_or_else(|_| Event::default().data("error"));
2823 if tx.send(Ok(sse_event)).is_err() {
2824 break;
2825 }
2826 first_sse_enqueue_us
2827 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2828 } else if buffer_structured_api_stream
2829 && parsed_final.content.trim().is_empty()
2830 && terminal_finish_reason != FinishReason::Length
2834 {
2835 let error_event = openai_error_sse_event(
2836 "model output did not satisfy tool/function call request",
2837 "internal_server_error",
2838 Some("tool_choice"),
2839 );
2840 let _ = tx.send(Ok(error_event));
2841 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2842 break;
2843 } else if !current_text.is_empty() {
2844 let content_delta =
2848 stream_text_delta(&parsed_final.content, &mut sent_content_len);
2849 let reasoning_delta = stream_text_delta(
2850 parsed_final.reasoning.as_deref().unwrap_or(""),
2851 &mut sent_reasoning_len,
2852 );
2853 if !content_delta.is_empty() || !reasoning_delta.is_empty() {
2854 let response_chunk = ChatCompletionsResponse {
2855 id: request_id.clone(),
2856 object: "chat.completion.chunk".to_string(),
2857 created: chrono::Utc::now().timestamp() as u64,
2858 model: openai_request.model.clone(),
2859 choices: vec![ChatChoice {
2860 index: 0,
2861 message: None,
2862 delta: Some(ChatMessage {
2863 role: MessageRole::Assistant,
2864 content: content_delta,
2865 reasoning: (!reasoning_delta.is_empty())
2866 .then_some(reasoning_delta),
2867 name: None,
2868 tool_calls: None,
2869 tool_call_id: None,
2870 function_call: None,
2871 }),
2872 finish_reason: None,
2873 }],
2874 usage: None,
2875 };
2876
2877 let sse_event = Event::default()
2878 .json_data(&response_chunk)
2879 .unwrap_or_else(|_| Event::default().data("error"));
2880 if tx.send(Ok(sse_event)).is_err() {
2881 break;
2882 }
2883 first_sse_enqueue_us
2884 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2885 }
2886 }
2887 let final_finish_reason = structured_chat_response
2897 .as_ref()
2898 .and_then(|response| response.finish_reason.clone())
2899 .or_else(|| chunk.finish_reason.as_ref().map(finish_reason_to_string))
2900 .or(Some("length".to_string()));
2901 let final_chunk = ChatCompletionsResponse {
2902 id: request_id.clone(),
2903 object: "chat.completion.chunk".to_string(),
2904 created: chrono::Utc::now().timestamp() as u64,
2905 model: openai_request.model.clone(),
2906 choices: vec![ChatChoice {
2907 index: 0,
2908 message: None,
2909 delta: Some(ChatMessage {
2910 role: MessageRole::Assistant,
2911 content: String::new(),
2912 reasoning: None,
2913 name: None,
2914 tool_calls: None,
2915 tool_call_id: None,
2916 function_call: None,
2917 }),
2918 finish_reason: final_finish_reason.clone(),
2919 }],
2920 usage: None,
2921 };
2922
2923 let final_event = Event::default()
2924 .json_data(&final_chunk)
2925 .unwrap_or_else(|_| Event::default().data("error"));
2926 if tx.send(Ok(final_event)).is_ok() {
2927 first_sse_enqueue_us
2928 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2929 }
2930 let completion_token_count = chunk
2931 .usage
2932 .as_ref()
2933 .map(|usage| usage.completion_tokens)
2934 .unwrap_or(output_token_ids.len());
2935 let replay_output_token_ids = chunk
2936 .execution_evidence
2937 .as_ref()
2938 .map(|evidence| evidence.output_token_ids.as_slice())
2939 .filter(|tokens| tokens.len() == completion_token_count)
2940 .unwrap_or(output_token_ids.as_slice());
2941 if let Err(err) = write_chat_request_completion_replay_bundle(
2942 request_dump_dir.as_ref().map(|root| root.as_path()),
2943 &replay_request_id,
2944 &parsed_final.content,
2945 replay_output_token_ids,
2946 final_finish_reason.as_deref(),
2947 ) {
2948 warn!("failed to write chat stream replay bundle: {}", err);
2949 }
2950 if let Err(err) = write_chat_request_profile_event(
2951 &profile_state,
2952 &replay_request_id,
2953 benchmark_correlation.as_ref(),
2954 &profile_request_model,
2955 true,
2956 "chat_completions_stream_complete",
2957 profile_started_at,
2958 ChatRequestProfileTiming {
2959 engine_evidence: chunk.execution_evidence.as_ref(),
2960 first_engine_chunk_received_us,
2961 first_sse_enqueue_us,
2962 },
2963 completion_token_count,
2964 chunk.usage.as_ref(),
2965 final_finish_reason.as_deref(),
2966 None,
2967 ) {
2968 warn!("failed to write chat stream profile event: {}", err);
2969 }
2970 if let Err(err) = maybe_write_first_request_memory_stage(
2971 &profile_state,
2972 &replay_request_id,
2973 benchmark_correlation.as_ref(),
2974 &profile_request_model,
2975 true,
2976 profile_started_at,
2977 request_memory_before,
2978 ) {
2979 warn!("failed to write chat stream memory profile event: {}", err);
2980 }
2981 if include_stream_usage && usage.is_some() {
2982 let usage_chunk = ChatCompletionsResponse {
2983 id: request_id.clone(),
2984 object: "chat.completion.chunk".to_string(),
2985 created: chrono::Utc::now().timestamp() as u64,
2986 model: openai_request.model.clone(),
2987 choices: vec![],
2988 usage,
2989 };
2990 let usage_event = Event::default()
2991 .json_data(&usage_chunk)
2992 .unwrap_or_else(|_| Event::default().data("error"));
2993 let _ = tx.send(Ok(usage_event));
2994 }
2995 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2996 break;
2997 }
2998 }
2999 Err(e) => {
3000 let failure_kind = e.observability_failure_kind();
3001 let error_kind = e.observability_error_kind();
3002 let error_message = e.to_string();
3003 let engine_status = if chat_resource_failure_kind(failure_kind) {
3004 Some(diagnostics_engine.status().await)
3005 } else {
3006 None
3007 };
3008 error!("Stream generation error: {}", error_message);
3009 if let Err(err) = write_chat_request_profile_event(
3010 &profile_state,
3011 &replay_request_id,
3012 benchmark_correlation.as_ref(),
3013 &profile_request_model,
3014 true,
3015 "chat_completions_stream_next",
3016 profile_started_at,
3017 ChatRequestProfileTiming {
3018 engine_evidence: None,
3019 first_engine_chunk_received_us,
3020 first_sse_enqueue_us,
3021 },
3022 output_token_ids.len(),
3023 None,
3024 Some("error"),
3025 Some(ProfileError {
3026 kind: error_kind.to_string(),
3027 message: error_message.clone(),
3028 blocking: false,
3029 }),
3030 ) {
3031 warn!("failed to write chat stream chunk profile event: {}", err);
3032 }
3033 if let Err(err) = write_chat_request_failure_diagnostics_at_root(
3034 request_dump_dir.as_ref().map(|root| root.as_path()),
3035 admission_summary.as_ref(),
3036 engine_status.as_ref(),
3037 &replay_request_id,
3038 failure_kind,
3039 "chat_completions_stream_next",
3040 error_kind,
3041 &error_message,
3042 ) {
3043 warn!(
3044 "failed to write chat stream chunk failure diagnostics: {}",
3045 err
3046 );
3047 }
3048 let _ = tx.send(Ok(openai_error_sse_event(
3049 error_message,
3050 "internal_server_error",
3051 None,
3052 )));
3053 let _ = tx.send(Ok(Event::default().data("[DONE]")));
3054 break;
3055 }
3056 }
3057 }
3058 });
3059
3060 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
3061 let sse_stream = Sse::new(stream);
3062
3063 Ok(sse_stream.into_response())
3064}
3065
3066async fn handle_chat_completions_sync(
3068 state: AppState,
3069 openai_request: ChatCompletionsRequest,
3070 inference_request: InferenceRequest,
3071 session_context: Option<SessionContext>,
3072 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
3073) -> std::result::Result<Response, ServerError> {
3074 info!("Processing non-streaming chat completion");
3075
3076 let engine = state.llm.clone().ok_or_else(|| {
3077 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
3078 })?;
3079 let request_chat_api = inference_request
3080 .api_request
3081 .as_ref()
3082 .and_then(|api_request| match api_request {
3083 ferrum_types::ApiRequest::Chat(chat_request) => {
3084 ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
3085 .then(|| chat_request.clone())
3086 }
3087 _ => None,
3088 });
3089 let model_output_protocol = inference_request.sampling_params.model_output_protocol;
3090 let started_in_think = request_started_in_reasoning(&inference_request);
3092 let mut native_projector = NativeChatOutputProjector::for_request(&inference_request);
3093 let replay_request_id = inference_request.id.to_string();
3094 let profile_request_model = openai_request.model.clone();
3095 let profile_started_at = Instant::now();
3096 let request_memory_before = request_memory_sample_before(&state);
3097 match engine.infer(inference_request).await {
3098 Ok(output) => {
3099 let InferenceResponse {
3100 text: output_text,
3101 tokens,
3102 finish_reason,
3103 usage,
3104 api_response,
3105 execution_evidence,
3106 ..
3107 } = output;
3108 if let Err(err) = write_chat_prompt_token_evidence(
3109 state.request_dump_dir.as_ref().map(|root| root.as_path()),
3110 &replay_request_id,
3111 &profile_request_model,
3112 execution_evidence.as_ref(),
3113 ) {
3114 warn!("failed to write chat prompt-token evidence: {}", err);
3115 }
3116
3117 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
3121 let content = strip_after_stop(&output_text, &stop_sequences);
3122 let native_projected = native_projector.take().map(|mut projector| {
3123 projector.push(&content);
3124 projector.finish(finish_reason)
3125 });
3126 let parsed_model_output = if let Some(projected) = native_projected.as_ref() {
3127 ParsedChatModelOutput {
3128 visible: projected.visible.clone(),
3129 harmony_response: None,
3130 }
3131 } else {
3132 parse_chat_model_output(
3133 model_output_protocol,
3134 &content,
3135 started_in_think,
3136 finish_reason,
3137 )?
3138 };
3139 let parsed = parsed_model_output.visible;
3140 let visible_content =
3141 normalize_structured_response_content(&openai_request, &parsed.content);
3142 let mut message = ChatMessage {
3143 role: MessageRole::Assistant,
3144 content: visible_content,
3145 reasoning: parsed.reasoning.clone(),
3146 name: None,
3147 tool_calls: None,
3148 tool_call_id: None,
3149 function_call: None,
3150 };
3151 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
3152 let mut structured_chat_response =
3153 finish_reason_allows_structured_api_response(finish_reason)
3154 .then(|| match api_response.as_ref() {
3155 _ if model_output_protocol == ModelOutputProtocol::HarmonyGptOss => {
3158 parsed_model_output.harmony_response.clone()
3159 }
3160 Some(ferrum_types::ApiResponse::Chat(chat_response)) => {
3161 Some(chat_response.clone())
3162 }
3163 _ if native_projected.is_some() => native_projected
3164 .as_ref()
3165 .and_then(|projected| projected.api_response.clone()),
3166 _ => match request_chat_api.as_ref() {
3167 Some(chat_request) => chat_api_response_from_parsed_generated_text(
3168 chat_request,
3169 &parsed,
3170 finish_reason,
3171 ),
3172 _ => None,
3173 },
3174 })
3175 .flatten();
3176 if native_projected.is_none()
3177 && matches!(api_response, Some(ferrum_types::ApiResponse::Chat(_)))
3178 {
3179 if let Some(response) = structured_chat_response.as_mut() {
3180 project_typed_tool_response_content(
3181 response,
3182 model_output_protocol,
3183 started_in_think,
3184 )?;
3185 }
3186 }
3187 if let Some(chat_response) = structured_chat_response.as_ref() {
3188 if let Err(error) =
3189 validate_structured_tool_response(&openai_request, chat_response)
3190 {
3191 if let Err(err) = write_chat_request_profile_event(
3192 &state,
3193 &replay_request_id,
3194 benchmark_correlation.as_ref(),
3195 &profile_request_model,
3196 false,
3197 "chat_completions_sync_tool_contract",
3198 profile_started_at,
3199 ChatRequestProfileTiming {
3200 engine_evidence: execution_evidence.as_ref(),
3201 ..Default::default()
3202 },
3203 tokens.len(),
3204 Some(&usage),
3205 Some("error"),
3206 Some(ProfileError {
3207 kind: "tool_contract_failure".to_string(),
3208 message: format!("{error:?}"),
3209 blocking: true,
3210 }),
3211 ) {
3212 warn!("failed to write chat tool-contract profile event: {}", err);
3213 }
3214 return Err(error);
3215 }
3216 message = openai_chat_message_from_api(&chat_response.message);
3217 if message.reasoning.is_none() {
3218 message.reasoning = parsed.reasoning.clone();
3219 }
3220 if let Some(reason) = &chat_response.finish_reason {
3221 openai_finish_reason = reason.clone();
3222 }
3223 } else if tool_choice_required(&openai_request) {
3224 log_required_tool_choice_failure(
3225 &openai_request,
3226 &parsed.content,
3227 parsed.reasoning.as_deref(),
3228 );
3229 if let Err(err) = write_chat_request_profile_event(
3230 &state,
3231 &replay_request_id,
3232 benchmark_correlation.as_ref(),
3233 &profile_request_model,
3234 false,
3235 "chat_completions_sync_tool_choice",
3236 profile_started_at,
3237 ChatRequestProfileTiming {
3238 engine_evidence: execution_evidence.as_ref(),
3239 ..Default::default()
3240 },
3241 tokens.len(),
3242 Some(&usage),
3243 Some("error"),
3244 Some(ProfileError {
3245 kind: "required_tool_failure".to_string(),
3246 message: "model output did not satisfy required tool_choice".to_string(),
3247 blocking: true,
3248 }),
3249 ) {
3250 warn!("failed to write chat tool-choice profile event: {}", err);
3251 }
3252 return Err(ServerError::invalid_request(
3253 "model output did not satisfy required tool_choice",
3254 Some("tool_choice"),
3255 ));
3256 }
3257 if let Err(error) = validate_hard_structured_response(
3258 &openai_request,
3259 &message.content,
3260 structured_chat_response.as_ref(),
3261 ) {
3262 if let Err(err) = write_chat_request_profile_event(
3263 &state,
3264 &replay_request_id,
3265 benchmark_correlation.as_ref(),
3266 &profile_request_model,
3267 false,
3268 "chat_completions_sync_structured_output",
3269 profile_started_at,
3270 ChatRequestProfileTiming {
3271 engine_evidence: execution_evidence.as_ref(),
3272 ..Default::default()
3273 },
3274 tokens.len(),
3275 Some(&usage),
3276 Some("error"),
3277 Some(ProfileError {
3278 kind: "structured_output_failure".to_string(),
3279 message: format!("{error:?}"),
3280 blocking: true,
3281 }),
3282 ) {
3283 warn!("failed to write chat strict-schema profile event: {}", err);
3284 }
3285 return Err(error);
3286 }
3287 if let Err(err) = write_chat_request_completion_replay_bundle(
3288 state.request_dump_dir.as_ref().map(|root| root.as_path()),
3289 &replay_request_id,
3290 &message.content,
3291 &tokens,
3292 Some(&openai_finish_reason),
3293 ) {
3294 warn!("failed to write chat completion replay bundle: {}", err);
3295 }
3296 if let Err(err) = write_chat_request_profile_event(
3297 &state,
3298 &replay_request_id,
3299 benchmark_correlation.as_ref(),
3300 &profile_request_model,
3301 false,
3302 "chat_completions_sync_complete",
3303 profile_started_at,
3304 ChatRequestProfileTiming {
3305 engine_evidence: execution_evidence.as_ref(),
3306 ..Default::default()
3307 },
3308 tokens.len(),
3309 Some(&usage),
3310 Some(&openai_finish_reason),
3311 None,
3312 ) {
3313 warn!("failed to write chat sync profile event: {}", err);
3314 }
3315 if let Err(err) = maybe_write_first_request_memory_stage(
3316 &state,
3317 &replay_request_id,
3318 benchmark_correlation.as_ref(),
3319 &profile_request_model,
3320 false,
3321 profile_started_at,
3322 request_memory_before,
3323 ) {
3324 warn!("failed to write chat sync memory profile event: {}", err);
3325 }
3326 state
3327 .cache
3328 .update_session(session_context, message.clone(), &CachePolicy::current());
3329 let response = ChatCompletionsResponse {
3330 id: replay_request_id,
3331 object: "chat.completion".to_string(),
3332 created: chrono::Utc::now().timestamp() as u64,
3333 model: openai_request.model,
3334 choices: vec![ChatChoice {
3335 index: 0,
3336 message: Some(message),
3337 delta: None,
3338 finish_reason: Some(openai_finish_reason),
3339 }],
3340 usage: Some(openai_usage_from_token_usage(&usage)),
3341 };
3342
3343 Ok(Json(response).into_response())
3344 }
3345 Err(e) => {
3346 let failure_kind = e.observability_failure_kind();
3347 let error_kind = e.observability_error_kind();
3348 let error_message = e.to_string();
3349 let engine_status = if chat_resource_failure_kind(failure_kind) {
3350 Some(engine.status().await)
3351 } else {
3352 None
3353 };
3354 error!("Generation failed: {}", error_message);
3355 if let Err(err) = write_chat_request_profile_event(
3356 &state,
3357 &replay_request_id,
3358 benchmark_correlation.as_ref(),
3359 &profile_request_model,
3360 false,
3361 "chat_completions_sync",
3362 profile_started_at,
3363 ChatRequestProfileTiming::default(),
3364 0,
3365 None,
3366 Some("error"),
3367 Some(ProfileError {
3368 kind: error_kind.to_string(),
3369 message: error_message.clone(),
3370 blocking: false,
3371 }),
3372 ) {
3373 warn!("failed to write chat sync failure profile event: {}", err);
3374 }
3375 if let Err(err) = write_chat_request_failure_diagnostics(
3376 &state,
3377 &replay_request_id,
3378 failure_kind,
3379 "chat_completions_sync",
3380 error_kind,
3381 &error_message,
3382 engine_status.as_ref(),
3383 ) {
3384 warn!(
3385 "failed to write chat generation failure diagnostics: {}",
3386 err
3387 );
3388 }
3389 Err(server_error_from_ferrum_error(e))
3390 }
3391 }
3392}
3393
3394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3395enum EffectiveChatOutputContract {
3396 RequiredToolCall,
3397 StrictJsonSchemaContent,
3398 JsonObjectContent,
3399 BestEffortJsonSchemaContent,
3400 Text,
3401}
3402
3403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3404enum ChatOutputBudget {
3405 AutoCeiling(u32),
3406 Explicit(u32),
3407}
3408
3409impl ChatOutputBudget {
3410 fn resolve(request: &ChatCompletionsRequest) -> Self {
3411 request
3412 .max_completion_tokens
3413 .or(request.max_tokens)
3414 .map(Self::Explicit)
3415 .unwrap_or(Self::AutoCeiling(DEFAULT_COMPLETION_MAX_TOKENS))
3416 }
3417
3418 const fn ceiling(self) -> u32 {
3419 match self {
3420 Self::AutoCeiling(value) | Self::Explicit(value) => value,
3421 }
3422 }
3423
3424 const fn is_auto(self) -> bool {
3425 matches!(self, Self::AutoCeiling(_))
3426 }
3427}
3428
3429impl EffectiveChatOutputContract {
3430 fn resolve(request: &ChatCompletionsRequest) -> Self {
3431 if tool_choice_required(request) {
3432 return Self::RequiredToolCall;
3433 }
3434 let Some(format) = request.response_format.as_ref() else {
3435 return Self::Text;
3436 };
3437 match format.format_type.as_str() {
3438 "json_schema"
3439 if format
3440 .json_schema
3441 .as_ref()
3442 .and_then(|schema| schema.strict)
3443 .unwrap_or(false) =>
3444 {
3445 Self::StrictJsonSchemaContent
3446 }
3447 "json_schema" => Self::BestEffortJsonSchemaContent,
3448 "json_object" => Self::JsonObjectContent,
3449 _ => Self::Text,
3450 }
3451 }
3452
3453 fn accepts_requested_response_format(self) -> bool {
3454 !matches!(self, Self::RequiredToolCall)
3455 }
3456}
3457
3458#[allow(dead_code)]
3460fn convert_chat_request(
3461 request: &ChatCompletionsRequest,
3462) -> ferrum_types::Result<InferenceRequest> {
3463 convert_chat_request_with_template_model(request, &request.model, None)
3464}
3465
3466fn request_started_in_reasoning(request: &InferenceRequest) -> bool {
3467 request
3468 .metadata
3469 .get(PROMPT_OPENED_REASONING_METADATA_KEY)
3470 .and_then(serde_json::Value::as_bool)
3471 .unwrap_or_else(|| {
3472 has_unclosed_model_reasoning_block(
3476 request.sampling_params.model_output_protocol,
3477 &request.prompt,
3478 )
3479 })
3480}
3481
3482fn convert_chat_request_with_template_model(
3489 request: &ChatCompletionsRequest,
3490 template_model_id: &str,
3491 model_template: Option<&ModelChatTemplate>,
3492) -> ferrum_types::Result<InferenceRequest> {
3493 convert_chat_request_with_template_model_and_default(
3494 request,
3495 template_model_id,
3496 model_template,
3497 None,
3498 true,
3499 None,
3500 )
3501}
3502
3503fn convert_chat_request_with_template_model_and_default(
3504 request: &ChatCompletionsRequest,
3505 template_model_id: &str,
3506 model_template: Option<&ModelChatTemplate>,
3507 default_enable_thinking: Option<bool>,
3508 interleaved_system_coalescing: bool,
3509 message_phases: Option<&[Option<AssistantMessagePhase>]>,
3510) -> ferrum_types::Result<InferenceRequest> {
3511 let no_tools: &[ChatTool] = &[];
3512 let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
3513 no_tools
3514 } else {
3515 request.tools.as_deref().unwrap_or_default()
3516 };
3517 let default_tool_choice =
3518 default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
3519 let effective_tool_choice = request
3520 .tool_choice
3521 .as_ref()
3522 .or(default_tool_choice.as_ref());
3523 let functions = request.functions.as_deref().unwrap_or_default();
3524 let model_output_protocol = model_template
3525 .map(|template| template.output_protocol)
3526 .unwrap_or(ModelOutputProtocol::Text);
3527 let output_contract = EffectiveChatOutputContract::resolve(request);
3528 let output_budget = ChatOutputBudget::resolve(request);
3529 let tool_call_protocol = model_template
3530 .map(|template| {
3531 if model_output_protocol == ModelOutputProtocol::HarmonyGptOss
3534 && template.tool_call_protocol == ferrum_types::ApiToolCallProtocol::NativeJson
3535 {
3536 ferrum_types::ApiToolCallProtocol::Json
3537 } else {
3538 template.tool_call_protocol
3539 }
3540 })
3541 .unwrap_or_default();
3542 let api_chat = api_chat_request(request, effective_tool_choice, tool_call_protocol);
3543 let native_tool_call_contract = api_chat.requires_native_tool_call();
3544 let forced_response_format = (model_output_protocol != ModelOutputProtocol::HarmonyGptOss
3548 && !native_tool_call_contract)
3549 .then(|| forced_tool_choice_response_format(request))
3550 .flatten();
3551 let hard_tool_call_contract = forced_response_format.is_some() || native_tool_call_contract;
3552 let requested_response_format = output_contract
3553 .accepts_requested_response_format()
3554 .then(|| requested_response_format_for_sampling(request))
3555 .transpose()?
3556 .flatten();
3557 let chat_template_options =
3558 chat_template_options_for_request(request, model_template, default_enable_thinking)?;
3559 let response_format = forced_response_format
3560 .or(requested_response_format)
3561 .unwrap_or(ferrum_types::ResponseFormat::Text);
3562 let model_generated_thinking = model_template.is_some_and(|template| {
3563 template.reasoning_protocol == ModelReasoningProtocol::ModelGenerated
3564 && template.reasoning_enabled(chat_template_options.enable_thinking)
3565 });
3566 let reasoning_enabled = model_template
3567 .is_some_and(|template| template.reasoning_enabled(chat_template_options.enable_thinking));
3568 let (render_messages, render_message_phases) = render_messages_with_response_format_instruction(
3569 request,
3570 output_contract,
3571 reasoning_enabled,
3572 model_template,
3573 message_phases,
3574 );
3575 let rendered_prompt = if tools.is_empty() && functions.is_empty() {
3576 render_chat_prompt_with_model_template_options_and_compatibility_with_prefill(
3577 &render_messages,
3578 template_model_id,
3579 model_template,
3580 &chat_template_options,
3581 interleaved_system_coalescing,
3582 Some(&render_message_phases),
3583 )?
3584 } else {
3585 render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill(
3586 &render_messages,
3587 template_model_id,
3588 model_template,
3589 &chat_template_options,
3590 tools,
3591 effective_tool_choice,
3592 functions,
3593 request.function_call.as_ref(),
3594 interleaved_system_coalescing,
3595 Some(&render_message_phases),
3596 )?
3597 };
3598 let prompt = rendered_prompt.text;
3599 let prompt_opened_thinking = rendered_prompt.reasoning_prefill;
3600 let mut metadata = HashMap::new();
3601 metadata.insert(
3602 PROMPT_OPENED_REASONING_METADATA_KEY.to_string(),
3603 serde_json::Value::Bool(prompt_opened_thinking),
3604 );
3605 metadata.insert(
3606 "openai_messages".to_string(),
3607 serde_json::to_value(&request.messages)?,
3608 );
3609 if let Some(tools) = &request.tools {
3610 metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
3611 }
3612 if let Some(tool_choice) = effective_tool_choice {
3613 metadata.insert(
3614 "openai_tool_choice".to_string(),
3615 serde_json::to_value(tool_choice)?,
3616 );
3617 }
3618 if let Some(functions) = &request.functions {
3619 metadata.insert(
3620 "openai_legacy_functions".to_string(),
3621 serde_json::to_value(functions)?,
3622 );
3623 }
3624 if let Some(function_call) = &request.function_call {
3625 metadata.insert(
3626 "openai_legacy_function_call".to_string(),
3627 serde_json::to_value(function_call)?,
3628 );
3629 }
3630 if request.ignore_eos.unwrap_or(false) {
3631 metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
3632 }
3633 if output_budget.is_auto() {
3634 metadata.insert(
3635 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
3636 serde_json::json!(true),
3637 );
3638 }
3639 let reasoning_markers = model_reasoning_markers(model_output_protocol);
3640 if !prompt_opened_thinking {
3641 let mut forbidden = reasoning_markers
3642 .map(|(_, close)| vec![close.to_string()])
3643 .unwrap_or_default();
3644 if hard_tool_call_contract {
3645 for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
3646 push_unique_forbidden_token_text(&mut forbidden, token_text);
3647 }
3648 if let Some(eos) = model_template.as_ref().and_then(|template| {
3649 template
3650 .eos_token
3651 .as_deref()
3652 .filter(|token| !token.is_empty())
3653 }) {
3654 push_unique_forbidden_token_text(&mut forbidden, eos);
3655 }
3656 }
3657 if model_output_protocol == ModelOutputProtocol::Text
3658 && chat_template_options.enable_thinking == Some(false)
3659 && model_template
3660 .is_some_and(|template| template.reasoning_protocol.supports_reasoning())
3661 {
3662 push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
3663 }
3664 metadata.insert(
3665 INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
3666 serde_json::json!(forbidden),
3667 );
3668 }
3669 let structured_output =
3670 !matches!(response_format, ferrum_types::ResponseFormat::Text) || native_tool_call_contract;
3671 let structured_output_after_reasoning = structured_output
3672 && model_output_protocol == ModelOutputProtocol::Text
3673 && (prompt_opened_thinking || model_generated_thinking);
3674 let structured_output_start =
3675 if structured_output && model_output_protocol == ModelOutputProtocol::HarmonyGptOss {
3676 StructuredOutputStart::HarmonyFinal
3677 } else if structured_output && model_output_protocol == ModelOutputProtocol::GemmaThought {
3678 let (opening, closing) = reasoning_markers.expect("Gemma thought markers");
3679 if prompt_opened_thinking {
3680 StructuredOutputStart::AfterDelimiter(closing.to_string())
3681 } else if prompt.trim_end().ends_with(closing) {
3682 StructuredOutputStart::Immediate
3683 } else {
3684 StructuredOutputStart::AfterReasoningEnvelope {
3685 opening: opening.to_string(),
3686 closing: closing.to_string(),
3687 allow_reasoning: reasoning_enabled,
3688 }
3689 }
3690 } else if structured_output_after_reasoning {
3691 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
3692 } else {
3693 StructuredOutputStart::Immediate
3694 };
3695 let delayed_grammar = matches!(
3696 structured_output_start,
3697 StructuredOutputStart::AfterDelimiter(_)
3698 | StructuredOutputStart::AfterReasoningEnvelope { .. }
3699 );
3700 let response_completion_boundary = if let Some((_, closing)) =
3701 reasoning_markers.filter(|_| prompt_opened_thinking || delayed_grammar)
3702 {
3703 ResponseCompletionBoundary::AfterDelimiterAndPayload {
3704 delimiter: closing.to_string(),
3705 alternate_envelope: api_chat.generated_response_envelope(),
3706 }
3707 } else {
3708 ResponseCompletionBoundary::Immediate
3709 };
3710
3711 Ok(InferenceRequest {
3712 id: RequestId(Uuid::new_v4()),
3713 model_id: ModelId(request.model.clone()),
3714 prompt,
3715 sampling_params: SamplingParams {
3716 max_tokens: output_budget.ceiling() as usize,
3717 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
3718 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
3719 top_k: request
3720 .top_k
3721 .filter(|value| *value > 0)
3722 .and_then(|value| usize::try_from(value).ok()),
3723 repetition_penalty: request
3724 .repetition_penalty
3725 .unwrap_or(DEFAULT_CHAT_REPETITION_PENALTY),
3726 presence_penalty: request.presence_penalty.unwrap_or(0.0),
3727 frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
3728 stop_sequences: request.stop.clone().unwrap_or_default(),
3729 seed: request.seed,
3730 min_p: request.min_p.filter(|value| *value > 0.0),
3731 tfs: None,
3732 typical_p: None,
3733 mirostat: None,
3734 response_format,
3735 structured_output_start,
3736 response_completion_boundary,
3737 model_output_protocol,
3738 },
3739 stream: request.stream.unwrap_or(false),
3740 priority: Priority::Normal, client_id: None,
3742 session_id: None,
3743 created_at: chrono::Utc::now(),
3744 api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
3745 evidence_request: Default::default(),
3746 metadata,
3747 })
3748}
3749
3750fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
3751 if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
3752 tokens.push(token.to_string());
3753 }
3754}
3755
3756fn default_auto_tool_choice_for_tools(
3757 tools: &[ChatTool],
3758 choice: Option<&ToolChoice>,
3759) -> Option<ToolChoice> {
3760 if choice.is_none() && !tools.is_empty() {
3761 Some(ToolChoice::Mode("auto".to_string()))
3762 } else {
3763 None
3764 }
3765}
3766
3767fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
3768 matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
3769}
3770
3771fn tool_choice_none_hides_tools(
3772 choice: Option<&ToolChoice>,
3773 model_template: Option<&ModelChatTemplate>,
3774) -> bool {
3775 tool_choice_none(choice)
3776 && model_template
3777 .map(|template| template.template.contains("tools_in_user_message"))
3778 .unwrap_or(false)
3779}
3780
3781fn chat_template_options_for_request(
3782 request: &ChatCompletionsRequest,
3783 model_template: Option<&ModelChatTemplate>,
3784 default_enable_thinking: Option<bool>,
3785) -> ferrum_types::Result<ChatTemplateOptions> {
3786 let mut options = ChatTemplateOptions::default_for_template(model_template);
3787 let kwargs = request.chat_template_kwargs.as_ref();
3788 let explicit_thinking = kwargs
3789 .and_then(|values| values.get("enable_thinking"))
3790 .filter(|value| !value.is_null())
3791 .map(|value| {
3792 value.as_bool().ok_or_else(|| {
3793 Error::invalid_request("chat_template_kwargs.enable_thinking must be a boolean")
3794 })
3795 })
3796 .transpose()?;
3797 let extension_effort = kwargs
3798 .and_then(|values| values.get("reasoning_effort"))
3799 .filter(|value| !value.is_null())
3800 .map(|value| {
3801 serde_json::from_value::<ReasoningEffort>(value.clone()).map_err(|error| {
3802 Error::invalid_request(format!("chat_template_kwargs.reasoning_effort: {error}"))
3803 })
3804 })
3805 .transpose()?;
3806 if let (Some(standard), Some(extension)) = (request.reasoning_effort, extension_effort) {
3807 if standard != extension {
3808 return Err(Error::invalid_request(
3809 "reasoning_effort conflicts with chat_template_kwargs.reasoning_effort",
3810 ));
3811 }
3812 }
3813 options.reasoning_effort = request.reasoning_effort.or(extension_effort);
3814 let effort_thinking = request
3815 .reasoning_effort
3816 .map(|effort| effort != ReasoningEffort::None);
3817 if let (Some(explicit), Some(derived)) = (explicit_thinking, effort_thinking) {
3818 if explicit != derived {
3819 return Err(Error::invalid_request(
3820 "reasoning_effort conflicts with chat_template_kwargs.enable_thinking",
3821 ));
3822 }
3823 }
3824 options.enable_thinking = explicit_thinking
3828 .or(effort_thinking)
3829 .or(default_enable_thinking);
3830 if let (Some(template), Some(effort)) = (model_template, request.reasoning_effort) {
3831 template.validate_reasoning_effort(effort)?;
3832 }
3833 Ok(options)
3834}
3835
3836fn render_messages_with_response_format_instruction(
3837 request: &ChatCompletionsRequest,
3838 output_contract: EffectiveChatOutputContract,
3839 reasoning_enabled: bool,
3840 model_template: Option<&ModelChatTemplate>,
3841 message_phases: Option<&[Option<AssistantMessagePhase>]>,
3842) -> (Vec<ChatMessage>, Vec<Option<AssistantMessagePhase>>) {
3843 let mut phases = message_phases
3844 .map(ToOwned::to_owned)
3845 .unwrap_or_else(|| vec![None; request.messages.len()]);
3846 debug_assert_eq!(phases.len(), request.messages.len());
3847 let Some(instruction) = response_format_prompt_instruction(
3848 request,
3849 output_contract,
3850 reasoning_enabled,
3851 model_template,
3852 ) else {
3853 return (request.messages.clone(), phases);
3854 };
3855 let mut messages = request.messages.clone();
3856 let leading_systems = messages
3857 .iter()
3858 .take_while(|message| message.role == MessageRole::System)
3859 .count();
3860 let mut system_parts = Vec::with_capacity(leading_systems + 1);
3861 system_parts.push(instruction);
3862 system_parts.extend(
3863 messages
3864 .drain(..leading_systems)
3865 .map(|message| message.content)
3866 .filter(|content| !content.is_empty()),
3867 );
3868 phases.drain(..leading_systems);
3869 messages.insert(
3870 0,
3871 ChatMessage {
3872 role: MessageRole::System,
3873 content: system_parts.join("\n\n"),
3874 reasoning: None,
3875 name: None,
3876 tool_calls: None,
3877 tool_call_id: None,
3878 function_call: None,
3879 },
3880 );
3881 phases.insert(0, None);
3882 (messages, phases)
3883}
3884
3885fn response_format_prompt_instruction(
3886 request: &ChatCompletionsRequest,
3887 output_contract: EffectiveChatOutputContract,
3888 reasoning_enabled: bool,
3889 model_template: Option<&ModelChatTemplate>,
3890) -> Option<String> {
3891 if !output_contract.accepts_requested_response_format() {
3892 return None;
3893 }
3894 let automatic_tools = request
3895 .tools
3896 .as_ref()
3897 .is_some_and(|tools| !tools.is_empty())
3898 && match request.tool_choice.as_ref() {
3899 None => true,
3900 Some(ToolChoice::Mode(mode)) => mode.eq_ignore_ascii_case("auto"),
3901 _ => false,
3902 }
3903 && matches!(
3904 output_contract,
3905 EffectiveChatOutputContract::StrictJsonSchemaContent
3906 | EffectiveChatOutputContract::JsonObjectContent
3907 );
3908 let native_tool_template =
3909 model_template.is_some_and(crate::chat_template::model_template_supports_tools);
3910 let tool_instruction = "If a tool is needed, put a JSON object with the declared function name in the name field and its argument object in the arguments field inside <tool_call>...</tool_call>. This envelope distinguishes a tool call from a final JSON answer.";
3911 if let Some(format) = request.response_format.as_ref() {
3912 return match format.format_type.as_str() {
3913 "json_object" if automatic_tools && native_tool_template => {
3914 Some(native_tool_final_format_instruction(r#"{"type":"object"}"#))
3915 }
3916 "json_object" if automatic_tools => Some(format!(
3917 "{tool_instruction} The response_format applies only to the final answer: output a single valid JSON object, with no markdown fences or extra text."
3918 )),
3919 "json_object" => Some(
3920 "The response_format requires a single valid JSON object. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text."
3921 .to_string(),
3922 ),
3923 "json_schema" => {
3924 let schema = format.json_schema.as_ref()?.schema.as_ref()?;
3925 let schema_text = serde_json::to_string(schema).ok()?;
3926 Some(if automatic_tools && native_tool_template {
3927 native_tool_final_format_instruction(&schema_text)
3928 } else if automatic_tools {
3929 format!(
3930 "{tool_instruction} Complete any enabled reasoning before the final answer. The response_format applies only to the final answer: output a single valid JSON value satisfying this JSON Schema, with no markdown fences or extra text. Schema: {schema_text}"
3931 )
3932 } else if reasoning_enabled {
3933 format!(
3934 "The response_format requires a single valid JSON value satisfying this JSON Schema. Complete any enabled reasoning before the final answer. In the final answer, output only JSON, with no markdown fences, no explanation, and no extra text. Schema: {schema_text}"
3935 )
3936 } else {
3937 format!(
3938 "The response_format requires a single valid JSON value satisfying this JSON Schema. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text. Schema: {schema_text}"
3939 )
3940 })
3941 }
3942 _ => None,
3943 };
3944 }
3945 None
3946}
3947
3948fn native_tool_final_format_instruction(schema: &str) -> String {
3949 format!(
3953 "# Response Format\n\nYour final response should be a JSON value that conforms to the following schema:\n\n{schema}\n\nDo not wrap your JSON response in Markdown code blocks."
3954 )
3955}
3956
3957fn forced_tool_choice_response_format(
3958 request: &ChatCompletionsRequest,
3959) -> Option<ferrum_types::ResponseFormat> {
3960 let selected_tool = selected_tool_for_forced_tool_choice(request)?;
3961 let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
3962 serde_json::to_string(&schema)
3963 .ok()
3964 .map(ferrum_types::ResponseFormat::JsonSchema)
3965}
3966
3967fn requested_response_format_for_sampling(
3968 request: &ChatCompletionsRequest,
3969) -> ferrum_types::Result<Option<ferrum_types::ResponseFormat>> {
3970 let Some(format) = request.response_format.as_ref() else {
3971 return Ok(None);
3972 };
3973 match format.format_type.as_str() {
3974 "json_object" => Ok(Some(ferrum_types::ResponseFormat::JsonObject)),
3975 "json_schema" => {
3976 let Some(schema) = format.json_schema.as_ref() else {
3977 return Err(Error::invalid_request(
3978 "response_format.json_schema.schema is required",
3979 ));
3980 };
3981 if !schema.strict.unwrap_or(false) {
3982 return Ok(None);
3983 }
3984 let Some(schema_value) = schema.schema.as_ref() else {
3985 return Err(Error::invalid_request(
3986 "response_format.json_schema.schema is required",
3987 ));
3988 };
3989 serde_json::to_string(schema_value)
3990 .map(|schema| Some(ferrum_types::ResponseFormat::JsonSchema(schema)))
3991 .map_err(|err| Error::invalid_request(err.to_string()))
3992 }
3993 _ => Ok(None),
3994 }
3995}
3996
3997fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
3998 match request.tool_choice.as_ref()? {
3999 ToolChoice::Function {
4000 tool_type,
4001 function,
4002 } if tool_type == "function" => request
4003 .tools
4004 .as_ref()?
4005 .iter()
4006 .find(|tool| tool.function.name == function.name),
4007 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
4008 single_function_tool(request.tools.as_deref()?)
4009 }
4010 _ => None,
4011 }
4012}
4013
4014fn guided_tool_arguments_schema(
4015 parameters: Option<&serde_json::Value>,
4016) -> Option<serde_json::Value> {
4017 let mut schema = parameters?.clone();
4018 bound_unconstrained_tool_argument_strings(
4019 &mut schema,
4020 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
4021 );
4022 Some(schema)
4023}
4024
4025fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
4026 match value {
4027 serde_json::Value::Object(map) => {
4028 let is_string = map
4029 .get("type")
4030 .and_then(serde_json::Value::as_str)
4031 .is_some_and(|ty| ty == "string");
4032 let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
4033 if is_string && !has_finite_string_shape {
4034 map.insert(
4035 "maxLength".to_string(),
4036 serde_json::Value::Number(default_max.into()),
4037 );
4038 }
4039 if let Some(properties) = map
4040 .get_mut("properties")
4041 .and_then(serde_json::Value::as_object_mut)
4042 {
4043 for property in properties.values_mut() {
4044 bound_unconstrained_tool_argument_strings(property, default_max);
4045 }
4046 }
4047 if let Some(items) = map.get_mut("items") {
4048 bound_unconstrained_tool_argument_strings(items, default_max);
4049 }
4050 }
4051 serde_json::Value::Array(items) => {
4052 for item in items {
4053 bound_unconstrained_tool_argument_strings(item, default_max);
4054 }
4055 }
4056 _ => {}
4057 }
4058}
4059
4060fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
4061 let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
4062 let tool = function_tools.next()?;
4063 function_tools.next().is_none().then_some(tool)
4064}
4065
4066fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
4067 if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
4068 let delta = text[*sent_len..].to_string();
4069 *sent_len = text.len();
4070 return delta;
4071 }
4072 *sent_len = text.len();
4073 String::new()
4074}
4075
4076fn project_typed_tool_response_content(
4077 response: &mut ferrum_types::ApiChatResponse,
4078 protocol: ModelOutputProtocol,
4079 started_in_think: bool,
4080) -> std::result::Result<(), ServerError> {
4081 if protocol == ModelOutputProtocol::HarmonyGptOss
4082 || response.message.tool_calls.is_empty()
4083 || response.message.content.is_empty()
4084 {
4085 return Ok(());
4086 }
4087 response.message.content =
4091 parse_model_reasoning_response(protocol, &response.message.content, started_in_think)
4092 .map_err(|error| ServerError::InternalError(error.to_string()))?
4093 .content;
4094 Ok(())
4095}
4096
4097fn chat_api_response_from_parsed_generated_text(
4098 chat_request: &ferrum_types::ApiChatRequest,
4099 parsed: &ParsedReasoningResponse,
4100 finish_reason: FinishReason,
4101) -> Option<ferrum_types::ApiChatResponse> {
4102 parsed
4103 .reasoning
4104 .as_deref()
4105 .and_then(|reasoning| {
4106 ferrum_types::chat_api_response_from_generated_text(
4107 chat_request,
4108 reasoning,
4109 finish_reason,
4110 )
4111 })
4112 .map(|mut response| {
4113 response.message.content.clear();
4116 response
4117 })
4118 .or_else(|| {
4119 ferrum_types::chat_api_response_from_generated_text(
4120 chat_request,
4121 &parsed.content,
4122 finish_reason,
4123 )
4124 })
4125}
4126
4127fn finish_reason_allows_structured_api_response(finish_reason: FinishReason) -> bool {
4128 matches!(finish_reason, FinishReason::Stop | FinishReason::EOS)
4129}
4130
4131fn log_required_tool_choice_failure(
4132 request: &ChatCompletionsRequest,
4133 content: &str,
4134 reasoning: Option<&str>,
4135) {
4136 warn!(
4137 model = %request.model,
4138 content_len = content.len(),
4139 content_head = %log_excerpt(content, 512),
4140 reasoning_len = reasoning.map(str::len).unwrap_or(0),
4141 reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
4142 "model output did not satisfy required tool_choice"
4143 );
4144}
4145
4146fn log_excerpt(value: &str, max_chars: usize) -> String {
4147 let mut out = value.chars().take(max_chars).collect::<String>();
4148 if value.chars().count() > max_chars {
4149 out.push_str("...");
4150 }
4151 out
4152}
4153
4154fn normalize_structured_response_content(
4155 request: &ChatCompletionsRequest,
4156 content: &str,
4157) -> String {
4158 match EffectiveChatOutputContract::resolve(request) {
4159 EffectiveChatOutputContract::BestEffortJsonSchemaContent => {
4160 extract_json_object_text(content)
4161 .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
4162 }
4163 EffectiveChatOutputContract::RequiredToolCall
4164 | EffectiveChatOutputContract::StrictJsonSchemaContent
4165 | EffectiveChatOutputContract::JsonObjectContent
4166 | EffectiveChatOutputContract::Text => content.to_string(),
4167 }
4168}
4169
4170fn extract_json_object_text(text: &str) -> Option<String> {
4171 let text = strip_markdown_json_fence(text.trim());
4172 if serde_json::from_str::<serde_json::Value>(&text)
4173 .ok()
4174 .filter(|value| value.is_object())
4175 .is_some()
4176 {
4177 return Some(text.to_string());
4178 }
4179
4180 let start = text.find('{')?;
4181 let mut depth = 0usize;
4182 let mut in_string = false;
4183 let mut escaped = false;
4184 for (offset, ch) in text[start..].char_indices() {
4185 if in_string {
4186 if escaped {
4187 escaped = false;
4188 } else if ch == '\\' {
4189 escaped = true;
4190 } else if ch == '"' {
4191 in_string = false;
4192 }
4193 continue;
4194 }
4195 match ch {
4196 '"' => in_string = true,
4197 '{' => depth += 1,
4198 '}' => {
4199 depth = depth.saturating_sub(1);
4200 if depth == 0 {
4201 let end = start + offset + ch.len_utf8();
4202 let candidate = &text[start..end];
4203 if serde_json::from_str::<serde_json::Value>(candidate)
4204 .ok()
4205 .filter(|value| value.is_object())
4206 .is_some()
4207 {
4208 return Some(candidate.to_string());
4209 }
4210 }
4211 }
4212 _ => {}
4213 }
4214 }
4215 None
4216}
4217
4218fn api_chat_request(
4219 request: &ChatCompletionsRequest,
4220 effective_tool_choice: Option<&ToolChoice>,
4221 tool_call_protocol: ferrum_types::ApiToolCallProtocol,
4222) -> ferrum_types::ApiChatRequest {
4223 ferrum_types::ApiChatRequest {
4224 messages: request.messages.iter().map(api_chat_message).collect(),
4225 tools: request
4226 .tools
4227 .as_deref()
4228 .unwrap_or_default()
4229 .iter()
4230 .map(api_tool)
4231 .collect(),
4232 tool_choice: effective_tool_choice.map(api_tool_choice),
4233 tool_call_protocol,
4234 legacy_functions: request
4235 .functions
4236 .as_deref()
4237 .unwrap_or_default()
4238 .iter()
4239 .map(api_function)
4240 .collect(),
4241 legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
4242 response_format: request.response_format.as_ref().map(api_response_format),
4243 stream_options: request.stream_options.as_ref().map(|opts| {
4244 ferrum_types::ApiStreamOptions {
4245 include_usage: opts.include_usage,
4246 }
4247 }),
4248 }
4249}
4250
4251fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
4252 ferrum_types::ApiChatMessage {
4253 role: match message.role {
4254 MessageRole::System => ferrum_types::ApiMessageRole::System,
4255 MessageRole::User => ferrum_types::ApiMessageRole::User,
4256 MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
4257 MessageRole::Function => ferrum_types::ApiMessageRole::Function,
4258 MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
4259 },
4260 content: message.content.clone(),
4261 name: message.name.clone(),
4262 tool_calls: message
4263 .tool_calls
4264 .as_deref()
4265 .unwrap_or_default()
4266 .iter()
4267 .map(api_tool_call)
4268 .collect(),
4269 tool_call_id: message.tool_call_id.clone(),
4270 function_call: message.function_call.as_ref().map(api_function_call),
4271 }
4272}
4273
4274fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
4275 ferrum_types::ApiTool {
4276 tool_type: tool.tool_type.clone(),
4277 function: api_function(&tool.function),
4278 }
4279}
4280
4281fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
4282 ferrum_types::ApiFunction {
4283 name: function.name.clone(),
4284 description: function.description.clone(),
4285 parameters: function.parameters.clone(),
4286 strict: function.strict,
4287 }
4288}
4289
4290fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
4291 match choice {
4292 ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
4293 ToolChoice::Function {
4294 tool_type,
4295 function,
4296 } => ferrum_types::ApiToolChoice::Function {
4297 tool_type: tool_type.clone(),
4298 function: ferrum_types::ApiToolChoiceFunction {
4299 name: function.name.clone(),
4300 },
4301 },
4302 }
4303}
4304
4305fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
4306 match choice {
4307 FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
4308 FunctionCallChoice::Function { name } => {
4309 ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
4310 }
4311 }
4312}
4313
4314fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
4315 ferrum_types::ApiToolCall {
4316 id: tool_call.id.clone(),
4317 tool_type: tool_call.tool_type.clone(),
4318 function: api_function_call(&tool_call.function),
4319 }
4320}
4321
4322fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
4323 ferrum_types::ApiFunctionCall {
4324 name: function_call.name.clone(),
4325 arguments: function_call.arguments.clone(),
4326 }
4327}
4328
4329fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4330 ChatMessage {
4331 role: openai_message_role_from_api(message.role),
4332 content: message.content.clone(),
4333 reasoning: None,
4334 name: message.name.clone(),
4335 tool_calls: if message.tool_calls.is_empty() {
4336 None
4337 } else {
4338 Some(
4339 message
4340 .tool_calls
4341 .iter()
4342 .map(openai_tool_call_from_api)
4343 .collect(),
4344 )
4345 },
4346 tool_call_id: message.tool_call_id.clone(),
4347 function_call: message
4348 .function_call
4349 .as_ref()
4350 .map(openai_function_call_from_api),
4351 }
4352}
4353
4354fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
4355 match role {
4356 ferrum_types::ApiMessageRole::System => MessageRole::System,
4357 ferrum_types::ApiMessageRole::User => MessageRole::User,
4358 ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
4359 ferrum_types::ApiMessageRole::Function => MessageRole::Function,
4360 ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
4361 }
4362}
4363
4364fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
4365 ChatToolCall {
4366 index: None,
4367 id: tool_call.id.clone(),
4368 tool_type: tool_call.tool_type.clone(),
4369 function: openai_function_call_from_api(&tool_call.function),
4370 }
4371}
4372
4373fn openai_tool_call_delta_from_api(
4374 index: usize,
4375 tool_call: &ferrum_types::ApiToolCall,
4376) -> ChatToolCall {
4377 ChatToolCall {
4378 index: Some(usize_to_u32_saturating(index)),
4379 id: tool_call.id.clone(),
4380 tool_type: tool_call.tool_type.clone(),
4381 function: openai_function_call_from_api(&tool_call.function),
4382 }
4383}
4384
4385fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4386 let mut delta = openai_chat_message_from_api(message);
4387 if !message.tool_calls.is_empty() {
4388 delta.tool_calls = Some(
4389 message
4390 .tool_calls
4391 .iter()
4392 .enumerate()
4393 .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
4394 .collect(),
4395 );
4396 }
4397 delta
4398}
4399
4400fn openai_function_call_from_api(
4401 function_call: &ferrum_types::ApiFunctionCall,
4402) -> ChatFunctionCall {
4403 ChatFunctionCall {
4404 name: function_call.name.clone(),
4405 arguments: function_call.arguments.clone(),
4406 }
4407}
4408
4409fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
4410 ferrum_types::ApiResponseFormat {
4411 format_type: format.format_type.clone(),
4412 json_schema: format
4413 .json_schema
4414 .as_ref()
4415 .map(|schema| ferrum_types::ApiJsonSchema {
4416 name: schema.name.clone(),
4417 schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
4418 strict: schema.strict,
4419 }),
4420 }
4421}
4422
4423fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
4424 if request.messages.is_empty() {
4425 return Err(ServerError::invalid_request(
4426 "messages array must not be empty",
4427 Some("messages"),
4428 ));
4429 }
4430
4431 if let Some(n) = request.n {
4432 if n != 1 {
4433 return Err(ServerError::unsupported_feature(
4434 "only n=1 is supported for chat completions",
4435 Some("n"),
4436 ));
4437 }
4438 }
4439
4440 if request
4441 .logit_bias
4442 .as_ref()
4443 .is_some_and(|bias| !bias.is_empty())
4444 {
4445 return Err(ServerError::unsupported_feature(
4446 "logit_bias is not supported",
4447 Some("logit_bias"),
4448 ));
4449 }
4450 if request.logprobs.unwrap_or(false) {
4451 return Err(ServerError::unsupported_feature(
4452 "logprobs is not supported",
4453 Some("logprobs"),
4454 ));
4455 }
4456 if request.top_logprobs.unwrap_or(0) > 0 {
4457 return Err(ServerError::unsupported_feature(
4458 "top_logprobs is not supported",
4459 Some("top_logprobs"),
4460 ));
4461 }
4462
4463 if let Some(top_k) = request.top_k {
4464 if top_k < -1 {
4465 return Err(ServerError::invalid_request(
4466 "top_k must be -1, 0, or a positive integer",
4467 Some("top_k"),
4468 ));
4469 }
4470 }
4471 if let Some(min_p) = request.min_p {
4472 if !min_p.is_finite() || !(0.0..=1.0).contains(&min_p) {
4473 return Err(ServerError::invalid_request(
4474 "min_p must be in range [0, 1]",
4475 Some("min_p"),
4476 ));
4477 }
4478 }
4479 if let Some(repetition_penalty) = request.repetition_penalty {
4480 if !repetition_penalty.is_finite() || repetition_penalty <= 0.0 {
4481 return Err(ServerError::invalid_request(
4482 "repetition_penalty must be positive",
4483 Some("repetition_penalty"),
4484 ));
4485 }
4486 }
4487 if let Some(presence_penalty) = request.presence_penalty {
4488 if !presence_penalty.is_finite() || !(-2.0..=2.0).contains(&presence_penalty) {
4489 return Err(ServerError::invalid_request(
4490 "presence_penalty must be in range [-2, 2]",
4491 Some("presence_penalty"),
4492 ));
4493 }
4494 }
4495 if let Some(frequency_penalty) = request.frequency_penalty {
4496 if !frequency_penalty.is_finite() || !(-2.0..=2.0).contains(&frequency_penalty) {
4497 return Err(ServerError::invalid_request(
4498 "frequency_penalty must be in range [-2, 2]",
4499 Some("frequency_penalty"),
4500 ));
4501 }
4502 }
4503
4504 if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
4505 return Err(ServerError::invalid_request(
4506 "stream_options is only valid when stream=true",
4507 Some("stream_options"),
4508 ));
4509 }
4510 ensure_response_format_supported(request)?;
4511
4512 if let Some(tools) = &request.tools {
4513 for tool in tools {
4514 if tool.tool_type != "function" {
4515 return Err(ServerError::unsupported_feature(
4516 "only function tools are supported",
4517 Some("tools"),
4518 ));
4519 }
4520 }
4521 }
4522
4523 if let Some(choice) = &request.tool_choice {
4524 match choice {
4525 ToolChoice::Mode(mode)
4526 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4527 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
4528 if request.tools.as_deref().unwrap_or_default().is_empty() {
4529 return Err(ServerError::invalid_request(
4530 "tool_choice=required requires at least one function tool",
4531 Some("tool_choice"),
4532 ));
4533 }
4534 }
4535 ToolChoice::Mode(_) => {
4536 return Err(ServerError::unsupported_feature(
4537 "unsupported tool_choice mode",
4538 Some("tool_choice"),
4539 ));
4540 }
4541 ToolChoice::Function {
4542 tool_type,
4543 function,
4544 } => {
4545 if tool_type != "function" {
4546 return Err(ServerError::unsupported_feature(
4547 "only function tool_choice is supported",
4548 Some("tool_choice"),
4549 ));
4550 }
4551 let declared = request
4552 .tools
4553 .as_deref()
4554 .unwrap_or_default()
4555 .iter()
4556 .any(|tool| tool.function.name == function.name);
4557 if !declared {
4558 return Err(ServerError::invalid_request(
4559 "tool_choice selects a function that is not declared in tools",
4560 Some("tool_choice"),
4561 ));
4562 }
4563 }
4564 }
4565 }
4566
4567 if let Some(choice) = &request.function_call {
4568 match choice {
4569 FunctionCallChoice::Mode(mode)
4570 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4571 FunctionCallChoice::Mode(_) => {
4572 return Err(ServerError::unsupported_feature(
4573 "unsupported function_call mode",
4574 Some("function_call"),
4575 ));
4576 }
4577 FunctionCallChoice::Function { name } => {
4578 let declared = request
4579 .functions
4580 .as_deref()
4581 .unwrap_or_default()
4582 .iter()
4583 .any(|function| function.name == *name);
4584 if !declared {
4585 return Err(ServerError::invalid_request(
4586 "function_call selects a function that is not declared in functions",
4587 Some("function_call"),
4588 ));
4589 }
4590 }
4591 }
4592 }
4593
4594 Ok(())
4595}
4596
4597fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
4598 match request.tool_choice.as_ref() {
4599 Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
4600 Some(ToolChoice::Function {
4601 tool_type,
4602 function,
4603 }) => {
4604 tool_type == "function"
4605 && request
4606 .tools
4607 .as_deref()
4608 .unwrap_or_default()
4609 .iter()
4610 .any(|tool| tool.function.name == function.name)
4611 }
4612 _ => false,
4613 }
4614}
4615
4616fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
4617 let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
4618 let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
4619 let total_tokens = usize_to_u32_saturating(usage.total_tokens);
4620 Usage {
4621 prompt_tokens,
4622 completion_tokens,
4623 total_tokens,
4624 }
4625}
4626
4627fn usize_to_u32_saturating(value: usize) -> u32 {
4628 u32::try_from(value).unwrap_or(u32::MAX)
4629}
4630
4631fn ensure_response_format_supported(
4632 request: &ChatCompletionsRequest,
4633) -> std::result::Result<(), ServerError> {
4634 if let Some(rf) = &request.response_format {
4635 match rf.format_type.as_str() {
4636 "text" | "json_object" => {}
4637 "json_schema" => {
4638 let Some(schema_config) = rf.json_schema.as_ref() else {
4639 return Err(ServerError::invalid_request(
4640 "response_format.json_schema.schema is required",
4641 Some("response_format.json_schema"),
4642 ));
4643 };
4644 let Some(schema) = schema_config.schema.as_ref() else {
4645 return Err(ServerError::invalid_request(
4646 "response_format.json_schema.schema is required",
4647 Some("response_format.json_schema"),
4648 ));
4649 };
4650 if schema_config.strict.unwrap_or(false) {
4651 compiled_json_schema_validator(schema).map_err(|reason| {
4652 ServerError::invalid_request(
4653 format!("unsupported strict json_schema: {reason}"),
4654 Some("response_format.json_schema"),
4655 )
4656 })?;
4657 }
4658 }
4659 _ => {
4660 return Err(ServerError::invalid_request(
4661 "unsupported response_format.type",
4662 Some("response_format.type"),
4663 ));
4664 }
4665 }
4666 }
4667 Ok(())
4668}
4669
4670fn strict_json_schema_string(
4671 request: &ChatCompletionsRequest,
4672) -> std::result::Result<Option<String>, ServerError> {
4673 let Some(rf) = &request.response_format else {
4674 return Ok(None);
4675 };
4676 if rf.format_type != "json_schema" {
4677 return Ok(None);
4678 }
4679 let Some(schema) = &rf.json_schema else {
4680 return Err(ServerError::invalid_request(
4681 "response_format.json_schema.schema is required",
4682 Some("response_format.json_schema"),
4683 ));
4684 };
4685 let Some(schema_value) = schema.schema.as_ref() else {
4686 return Err(ServerError::invalid_request(
4687 "response_format.json_schema.schema is required",
4688 Some("response_format.json_schema"),
4689 ));
4690 };
4691 if !schema.strict.unwrap_or(false) {
4692 return Ok(None);
4693 }
4694 serde_json::to_string(schema_value).map(Some).map_err(|e| {
4695 ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
4696 })
4697}
4698
4699fn validate_hard_structured_response(
4700 request: &ChatCompletionsRequest,
4701 content: &str,
4702 validated_chat_response: Option<&ferrum_types::ApiChatResponse>,
4703) -> std::result::Result<(), ServerError> {
4704 if validated_chat_response.is_some_and(|response| !response.message.tool_calls.is_empty()) {
4708 return Ok(());
4709 }
4710 let content = validated_chat_response
4713 .map(|response| response.message.content.as_str())
4714 .unwrap_or(content);
4715 match EffectiveChatOutputContract::resolve(request) {
4716 EffectiveChatOutputContract::JsonObjectContent => {
4717 let value = serde_json::from_str::<serde_json::Value>(content).map_err(|error| {
4718 ServerError::InternalError(format!(
4719 "model output did not satisfy response_format.json_object: invalid JSON: {error}"
4720 ))
4721 })?;
4722 if !value.is_object() {
4723 return Err(ServerError::InternalError(
4724 "model output did not satisfy response_format.json_object: root must be an object"
4725 .to_string(),
4726 ));
4727 }
4728 Ok(())
4729 }
4730 EffectiveChatOutputContract::StrictJsonSchemaContent => {
4731 let Some(schema_json) = strict_json_schema_string(request)? else {
4732 return Ok(());
4733 };
4734 let schema: serde_json::Value = serde_json::from_str(&schema_json).map_err(|e| {
4735 ServerError::InternalError(format!(
4736 "strict json_schema could not be reconstructed after request validation: {e}"
4737 ))
4738 })?;
4739 validate_json_text_against_schema(&schema, content).map_err(|reason| {
4740 ServerError::InternalError(format!(
4741 "model output did not satisfy response_format.json_schema.strict: {reason}"
4742 ))
4743 })
4744 }
4745 EffectiveChatOutputContract::RequiredToolCall
4746 | EffectiveChatOutputContract::BestEffortJsonSchemaContent
4747 | EffectiveChatOutputContract::Text => Ok(()),
4748 }
4749}
4750
4751fn structured_response_error_param(contract: EffectiveChatOutputContract) -> Option<&'static str> {
4752 match contract {
4753 EffectiveChatOutputContract::JsonObjectContent => Some("response_format"),
4754 EffectiveChatOutputContract::StrictJsonSchemaContent => Some("response_format.json_schema"),
4755 _ => None,
4756 }
4757}
4758
4759fn validate_structured_tool_response(
4760 request: &ChatCompletionsRequest,
4761 response: &ferrum_types::ApiChatResponse,
4762) -> std::result::Result<(), ServerError> {
4763 let required = tool_choice_required(request);
4764 if response.message.tool_calls.is_empty() {
4765 if required {
4766 return Err(ServerError::invalid_request(
4767 "model output did not satisfy required tool_choice",
4768 Some("tool_choice"),
4769 ));
4770 }
4771 return Ok(());
4772 }
4773
4774 if tool_choice_none(request.tool_choice.as_ref()) {
4775 return Err(ServerError::InternalError(
4776 "model emitted a tool call while tool_choice is 'none'".to_string(),
4777 ));
4778 }
4779
4780 if required {
4781 if !response.message.content.trim().is_empty() {
4782 return Err(ServerError::InternalError(
4783 "required tool response contained assistant content".to_string(),
4784 ));
4785 }
4786 if response.finish_reason.as_deref() != Some("tool_calls") {
4787 return Err(ServerError::InternalError(
4788 "required tool response did not finish with tool_calls".to_string(),
4789 ));
4790 }
4791 }
4792
4793 let tools = request.tools.as_deref().unwrap_or_default();
4794 for call in &response.message.tool_calls {
4795 if call.tool_type != "function" {
4796 return Err(ServerError::InternalError(format!(
4797 "model emitted unsupported tool call type '{}'",
4798 call.tool_type
4799 )));
4800 }
4801 let Some(tool) = tools
4802 .iter()
4803 .find(|tool| tool.tool_type == "function" && tool.function.name == call.function.name)
4804 else {
4805 return Err(ServerError::InternalError(format!(
4806 "model emitted undeclared tool call '{}'",
4807 call.function.name
4808 )));
4809 };
4810 if let Some(ToolChoice::Function {
4811 tool_type,
4812 function,
4813 }) = request.tool_choice.as_ref()
4814 {
4815 if tool_type != "function" || function.name != call.function.name {
4816 return Err(ServerError::InternalError(format!(
4817 "model emitted tool '{}' instead of selected tool '{}'",
4818 call.function.name, function.name
4819 )));
4820 }
4821 }
4822
4823 let arguments: serde_json::Value =
4824 serde_json::from_str(&call.function.arguments).map_err(|e| {
4825 ServerError::InternalError(format!(
4826 "model emitted invalid JSON arguments for tool '{}': {e}",
4827 call.function.name
4828 ))
4829 })?;
4830 if !arguments.is_object() {
4831 return Err(ServerError::InternalError(format!(
4832 "model emitted non-object arguments for tool '{}'",
4833 call.function.name
4834 )));
4835 }
4836 if let Some(schema) = tool
4841 .function
4842 .parameters
4843 .as_ref()
4844 .filter(|_| required || tool.function.strict.unwrap_or(false))
4845 {
4846 validate_json_text_against_schema(schema, &call.function.arguments).map_err(
4847 |reason| {
4848 ServerError::InternalError(format!(
4849 "model arguments for tool '{}' did not satisfy its schema: {reason}",
4850 call.function.name
4851 ))
4852 },
4853 )?;
4854 }
4855 }
4856 Ok(())
4857}
4858
4859fn validate_json_text_against_schema(
4860 schema: &serde_json::Value,
4861 content: &str,
4862) -> std::result::Result<(), String> {
4863 let value = serde_json::from_str::<serde_json::Value>(content)
4864 .map_err(|e| format!("invalid JSON: {e}"))?;
4865 compiled_json_schema_validator(schema)?
4866 .validate(&value)
4867 .map_err(|error| error.to_string())
4868}
4869
4870fn compiled_json_schema_validator(
4871 schema: &serde_json::Value,
4872) -> std::result::Result<Arc<jsonschema::Validator>, String> {
4873 let cache_key = serde_json::to_string(schema)
4874 .map_err(|error| format!("could not serialize JSON Schema: {error}"))?;
4875 let cache = JSON_SCHEMA_VALIDATOR_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
4876 let mut validators = cache
4877 .lock()
4878 .map_err(|_| "JSON Schema validator cache lock was poisoned".to_string())?;
4879 if let Some(validator) = validators.get(&cache_key) {
4880 return Ok(Arc::clone(validator));
4881 }
4882
4883 let validator = Arc::new(
4884 jsonschema::validator_for(schema)
4885 .map_err(|error| format!("could not compile JSON Schema: {error}"))?,
4886 );
4887 if validators.len() >= MAX_CACHED_JSON_SCHEMA_VALIDATORS {
4888 validators.clear();
4889 }
4890 validators.insert(cache_key, Arc::clone(&validator));
4891 Ok(validator)
4892}
4893
4894fn stream_validation_error_message(error: ServerError) -> String {
4895 match error {
4896 ServerError::InternalError(message)
4897 | ServerError::NotImplemented(message)
4898 | ServerError::ServiceUnavailable(message)
4899 | ServerError::ContextLengthExceeded(message)
4900 | ServerError::InvalidRequest { message, .. }
4901 | ServerError::UnsupportedFeature { message, .. } => message,
4902 }
4903}
4904
4905fn server_error_from_ferrum_error(error: Error) -> ServerError {
4906 match error {
4907 Error::RequestValidation { message } => ServerError::invalid_request(message, None),
4908 error @ Error::ContextLengthExceeded { .. } => {
4909 ServerError::ContextLengthExceeded(error.to_string())
4910 }
4911 Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
4912 other => ServerError::InternalError(other.to_string()),
4913 }
4914}
4915
4916fn stream_error_payload(
4917 message: impl Into<String>,
4918 error_type: &str,
4919 param: Option<&str>,
4920) -> OpenAiError {
4921 OpenAiError {
4922 error: OpenAiErrorDetail {
4923 message: message.into(),
4924 error_type: error_type.to_string(),
4925 param: param.map(str::to_string),
4926 code: None,
4927 },
4928 }
4929}
4930
4931fn openai_error_sse_event(
4932 message: impl Into<String>,
4933 error_type: &str,
4934 param: Option<&str>,
4935) -> Event {
4936 Event::default()
4937 .json_data(&stream_error_payload(message, error_type, param))
4938 .unwrap_or_else(|_| Event::default().data("error"))
4939}
4940
4941fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
4942 let prompt = request
4943 .prompt
4944 .as_text()
4945 .expect("completion prompt validated before conversion");
4946 InferenceRequest {
4947 id: RequestId(Uuid::new_v4()),
4948 model_id: ModelId(request.model.clone()),
4949 prompt: prompt.to_string(),
4950 sampling_params: SamplingParams {
4951 max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
4952 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
4953 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
4954 top_k: None,
4955 repetition_penalty: 1.0,
4956 presence_penalty: 0.0,
4957 frequency_penalty: 0.0,
4958 stop_sequences: request.stop.clone().unwrap_or_default(),
4959 seed: None,
4960 min_p: None,
4961 tfs: None,
4962 typical_p: None,
4963 mirostat: None,
4964 response_format: ferrum_types::ResponseFormat::Text,
4965 structured_output_start: StructuredOutputStart::Immediate,
4966 response_completion_boundary: ResponseCompletionBoundary::Immediate,
4967 model_output_protocol: ferrum_types::ModelOutputProtocol::Text,
4968 },
4969 stream: request.stream.unwrap_or(false),
4970 priority: Priority::Normal,
4971 client_id: None,
4972 session_id: None,
4973 created_at: chrono::Utc::now(),
4974 api_request: Some(ferrum_types::ApiRequest::Completion(
4975 ferrum_types::ApiCompletionRequest {
4976 prompt: prompt.to_string(),
4977 response_format: None,
4978 },
4979 )),
4980 evidence_request: Default::default(),
4981 metadata: if request.max_tokens.is_none() {
4982 HashMap::from([(
4983 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
4984 serde_json::json!(true),
4985 )])
4986 } else {
4987 HashMap::new()
4988 },
4989 }
4990}
4991
4992fn resolve_request_model<'a>(
4993 registry: &'a ServedModelRegistry,
4994 request_model: &str,
4995 required_kind: ServedModelKind,
4996) -> std::result::Result<(ModelId, Option<&'a LoraAdapterModel>), ServerError> {
4997 if registry.is_empty() {
4998 return Ok((ModelId::new(request_model), None));
4999 }
5000 let entry = registry
5001 .resolve(request_model, required_kind)
5002 .ok_or_else(|| {
5003 ServerError::invalid_request(format!("unknown model: {request_model}"), Some("model"))
5004 })?;
5005 Ok((entry.engine_model_id().clone(), entry.adapter()))
5006}
5007
5008fn apply_served_model_resolution(
5009 inference_request: &mut InferenceRequest,
5010 engine_model_id: ModelId,
5011 adapter: Option<&LoraAdapterModel>,
5012) {
5013 inference_request.model_id = engine_model_id;
5014 if let Some(adapter) = adapter {
5015 inference_request.metadata.insert(
5016 "ferrum_lora_adapter".to_string(),
5017 serde_json::json!(adapter.name),
5018 );
5019 inference_request.metadata.insert(
5020 "ferrum_lora_model_id".to_string(),
5021 serde_json::json!(adapter.model_id),
5022 );
5023 inference_request.metadata.insert(
5024 "ferrum_lora_path".to_string(),
5025 serde_json::json!(adapter.path),
5026 );
5027 }
5028}
5029
5030async fn handle_completions_sync(
5031 state: AppState,
5032 openai_request: CompletionsRequest,
5033 inference_request: InferenceRequest,
5034) -> std::result::Result<Response, ServerError> {
5035 let engine = state.llm.clone().ok_or_else(|| {
5036 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
5037 })?;
5038 match engine.infer(inference_request).await {
5039 Ok(output) => {
5040 let InferenceResponse {
5041 text: output_text,
5042 finish_reason,
5043 usage,
5044 api_response,
5045 ..
5046 } = output;
5047 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
5048 let mut text = strip_after_stop(&output_text, &stop_sequences);
5049 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
5050 if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
5051 api_response.as_ref()
5052 {
5053 text = strip_after_stop(&completion_response.text, &stop_sequences);
5054 if let Some(reason) = &completion_response.finish_reason {
5055 openai_finish_reason = reason.clone();
5056 }
5057 }
5058 let response = CompletionsResponse {
5059 id: Uuid::new_v4().to_string(),
5060 object: "text_completion".to_string(),
5061 created: chrono::Utc::now().timestamp() as u64,
5062 model: openai_request.model,
5063 choices: vec![CompletionChoice {
5064 text,
5065 index: 0,
5066 finish_reason: Some(openai_finish_reason),
5067 }],
5068 usage: Some(openai_usage_from_token_usage(&usage)),
5069 };
5070 Ok(Json(response).into_response())
5071 }
5072 Err(e) => {
5073 error!("Completion generation failed: {}", e);
5074 Err(server_error_from_ferrum_error(e))
5075 }
5076 }
5077}
5078
5079async fn handle_completions_stream(
5080 state: AppState,
5081 openai_request: CompletionsRequest,
5082 inference_request: InferenceRequest,
5083) -> std::result::Result<Response, ServerError> {
5084 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
5085 let engine = state.llm.clone().ok_or_else(|| {
5086 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
5087 })?;
5088 let request_id = Uuid::new_v4().to_string();
5089
5090 let mut stream = engine.infer_stream(inference_request).await.map_err(|e| {
5093 error!("Failed to start completion stream: {}", e);
5094 server_error_from_ferrum_error(e)
5095 })?;
5096 tokio::spawn(async move {
5097 while let Some(result) = stream.next().await {
5098 match result {
5099 Ok(chunk) => {
5100 let response_chunk = CompletionsResponse {
5101 id: request_id.clone(),
5102 object: "text_completion".to_string(),
5103 created: chrono::Utc::now().timestamp() as u64,
5104 model: openai_request.model.clone(),
5105 choices: vec![CompletionChoice {
5106 text: chunk.text.clone(),
5107 index: 0,
5108 finish_reason: chunk
5109 .finish_reason
5110 .as_ref()
5111 .map(finish_reason_to_string),
5112 }],
5113 usage: None,
5114 };
5115 let event = Event::default()
5116 .json_data(&response_chunk)
5117 .unwrap_or_else(|_| Event::default().data("error"));
5118 if tx.send(Ok(event)).is_err() {
5119 break;
5120 }
5121 if chunk.finish_reason.is_some() {
5122 if let Some(usage) = chunk.usage.as_ref().map(openai_usage_from_token_usage)
5123 {
5124 let final_chunk = CompletionsResponse {
5125 id: request_id.clone(),
5126 object: "text_completion".to_string(),
5127 created: chrono::Utc::now().timestamp() as u64,
5128 model: openai_request.model.clone(),
5129 choices: vec![],
5130 usage: Some(usage),
5131 };
5132 let event = Event::default()
5133 .json_data(&final_chunk)
5134 .unwrap_or_else(|_| Event::default().data("error"));
5135 let _ = tx.send(Ok(event));
5136 }
5137 let _ = tx.send(Ok(Event::default().data("[DONE]")));
5138 break;
5139 }
5140 }
5141 Err(e) => {
5142 error!("Completion stream generation error: {}", e);
5143 let _ = tx.send(Ok(openai_error_sse_event(
5144 e.to_string(),
5145 "internal_server_error",
5146 None,
5147 )));
5148 let _ = tx.send(Ok(Event::default().data("[DONE]")));
5149 break;
5150 }
5151 }
5152 }
5153 });
5154
5155 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
5156 Ok(Sse::new(stream).into_response())
5157}
5158
5159async fn completions_handler(
5161 State(state): State<AppState>,
5162 request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
5163) -> std::result::Result<Response, ServerError> {
5164 let Json(request) = request.map_err(|e| {
5165 ServerError::invalid_request(format!("invalid completions request: {e}"), None)
5166 })?;
5167 validate_completion_request(&request)?;
5168 let (engine_model_id, lora_adapter) = resolve_request_model(
5169 &state.served_model_registry,
5170 &request.model,
5171 ServedModelKind::Llm,
5172 )?;
5173 let mut inference_request = convert_completion_request(&request);
5174 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
5175 if request.stream.unwrap_or(false) {
5176 handle_completions_stream(state, request, inference_request).await
5177 } else {
5178 handle_completions_sync(state, request, inference_request).await
5179 }
5180}
5181
5182fn validate_completion_request(
5183 request: &CompletionsRequest,
5184) -> std::result::Result<(), ServerError> {
5185 if request.prompt.as_text().is_none() {
5186 return Err(ServerError::invalid_request(
5187 "only string prompt is supported for completions",
5188 Some("prompt"),
5189 ));
5190 }
5191 if let Some(n) = request.n {
5192 if n != 1 {
5193 return Err(ServerError::unsupported_feature(
5194 "only n=1 is supported for completions",
5195 Some("n"),
5196 ));
5197 }
5198 }
5199 if request.logprobs.is_some() {
5200 return Err(ServerError::unsupported_feature(
5201 "logprobs is not supported for completions",
5202 Some("logprobs"),
5203 ));
5204 }
5205 if request
5206 .logit_bias
5207 .as_ref()
5208 .is_some_and(|bias| !bias.is_empty())
5209 {
5210 return Err(ServerError::unsupported_feature(
5211 "logit_bias is not supported",
5212 Some("logit_bias"),
5213 ));
5214 }
5215 Ok(())
5216}
5217
5218async fn embeddings_handler(
5220 State(state): State<AppState>,
5221 request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
5222) -> std::result::Result<Response, ServerError> {
5223 let Json(request) = request.map_err(|e| {
5224 ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
5225 })?;
5226
5227 let span = span!(Level::INFO, "embeddings", model = %request.model);
5228 let _enter = span.enter();
5229
5230 validate_embeddings_request(&request)?;
5231 resolve_request_model(
5232 &state.served_model_registry,
5233 &request.model,
5234 ServedModelKind::Embedding,
5235 )?;
5236
5237 let items: Vec<EmbeddingItem> = match request.input {
5239 EmbeddingInput::Single(text) => vec![EmbeddingItem {
5240 text: Some(text),
5241 image: None,
5242 }],
5243 EmbeddingInput::Batch(texts) => texts
5244 .into_iter()
5245 .map(|t| EmbeddingItem {
5246 text: Some(t),
5247 image: None,
5248 })
5249 .collect(),
5250 EmbeddingInput::SingleObject(item) => vec![item],
5251 EmbeddingInput::BatchObjects(items) => items,
5252 };
5253
5254 if items.is_empty() {
5255 return Err(ServerError::invalid_request(
5256 "input must not be empty",
5257 Some("input"),
5258 ));
5259 }
5260
5261 let mut data = Vec::with_capacity(items.len());
5262 let mut total_tokens = 0u32;
5263
5264 let engine = state.embed.as_ref().ok_or_else(|| {
5265 ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
5266 })?;
5267 for (idx, item) in items.iter().enumerate() {
5268 let embedding = if let Some(ref image) = item.image {
5269 engine
5270 .embed_image(image)
5271 .await
5272 .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
5273 } else if let Some(ref text) = item.text {
5274 total_tokens += text.len() as u32;
5275 engine
5276 .embed_text(text)
5277 .await
5278 .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
5279 } else {
5280 return Err(ServerError::invalid_request(
5281 "each input item must have either text or image",
5282 Some("input"),
5283 ));
5284 };
5285
5286 data.push(EmbeddingData {
5287 object: "embedding".to_string(),
5288 embedding,
5289 index: idx,
5290 });
5291 }
5292
5293 let response = EmbeddingsResponse {
5294 object: "list".to_string(),
5295 data,
5296 model: request.model,
5297 usage: EmbeddingUsage {
5298 prompt_tokens: total_tokens,
5299 total_tokens,
5300 },
5301 };
5302
5303 Ok(Json(response).into_response())
5304}
5305
5306fn validate_embeddings_request(
5307 request: &EmbeddingsRequest,
5308) -> std::result::Result<(), ServerError> {
5309 if let Some(format) = request.encoding_format.as_deref() {
5310 if !format.eq_ignore_ascii_case("float") {
5311 return Err(ServerError::unsupported_feature(
5312 "only encoding_format=float is supported for embeddings",
5313 Some("encoding_format"),
5314 ));
5315 }
5316 }
5317 Ok(())
5318}
5319
5320async fn transcriptions_handler(
5322 State(state): State<AppState>,
5323 multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
5324) -> std::result::Result<Response, ServerError> {
5325 let mut multipart = multipart.map_err(|e| {
5326 ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
5327 })?;
5328
5329 let span = span!(Level::INFO, "transcription");
5330 let _enter = span.enter();
5331
5332 let mut file_data: Option<Vec<u8>> = None;
5333 let mut language: Option<String> = None;
5334 let mut response_format: Option<String> = None;
5335
5336 while let Some(field) = multipart
5337 .next_field()
5338 .await
5339 .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
5340 {
5341 let name = field.name().unwrap_or("").to_string();
5342 match name.as_str() {
5343 "file" => {
5344 file_data = Some(
5345 field
5346 .bytes()
5347 .await
5348 .map_err(|e| {
5349 ServerError::invalid_request(format!("read file: {e}"), Some("file"))
5350 })?
5351 .to_vec(),
5352 );
5353 }
5354 "language" => {
5355 language = field.text().await.ok().filter(|s| !s.is_empty());
5356 }
5357 "response_format" => {
5358 response_format = field.text().await.ok().filter(|s| !s.is_empty());
5359 }
5360 _ => {} }
5362 }
5363
5364 validate_transcription_response_format(response_format.as_deref())?;
5365
5366 let data = file_data
5367 .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
5368
5369 let engine = state.transcribe.as_ref().ok_or_else(|| {
5370 ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
5371 })?;
5372 let text = engine
5373 .transcribe_bytes(&data, language.as_deref())
5374 .await
5375 .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
5376
5377 Ok(Json(TranscriptionResponse { text }).into_response())
5378}
5379
5380fn validate_transcription_response_format(
5381 response_format: Option<&str>,
5382) -> std::result::Result<(), ServerError> {
5383 if let Some(format) = response_format {
5384 if !format.eq_ignore_ascii_case("json") {
5385 return Err(ServerError::unsupported_feature(
5386 "only response_format=json is supported for transcriptions",
5387 Some("response_format"),
5388 ));
5389 }
5390 }
5391 Ok(())
5392}
5393
5394async fn speech_handler(
5396 State(state): State<AppState>,
5397 request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
5398) -> std::result::Result<Response, ServerError> {
5399 let Json(request) = request
5400 .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
5401
5402 let response_format = speech_output_format(&request)?;
5403 resolve_request_model(
5404 &state.served_model_registry,
5405 &request.model,
5406 ServedModelKind::Speech,
5407 )?;
5408
5409 let span = span!(Level::INFO, "speech");
5410 let _guard = span.enter();
5411
5412 let language = if request.language.is_empty() || request.language == "auto" {
5413 None
5414 } else {
5415 Some(request.language.as_str())
5416 };
5417
5418 let chunk_frames = 10usize;
5419 let tts = state.tts.as_ref().ok_or_else(|| {
5420 ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
5421 })?;
5422 let sample_rate = tts.tts_sample_rate();
5423
5424 if request.stream {
5425 let (tx, rx) =
5427 mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
5428
5429 let engine = tts.clone();
5430 let text = request.input.clone();
5431 let lang = request.language.clone();
5432
5433 tokio::task::spawn_blocking(move || {
5434 let lang_opt = if lang.is_empty() || lang == "auto" {
5435 None
5436 } else {
5437 Some(lang.as_str())
5438 };
5439 let rt = tokio::runtime::Handle::current();
5440
5441 match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
5442 Ok(chunks) => {
5443 for chunk in &chunks {
5444 let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
5445 let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
5446 }
5447 }
5448 Err(e) => {
5449 error!("TTS error: {e}");
5450 }
5451 }
5452 });
5453
5454 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
5455 let body = axum::body::Body::from_stream(stream);
5456 Ok(Response::builder()
5457 .status(200)
5458 .header("content-type", speech_content_type(response_format))
5459 .header("transfer-encoding", "chunked")
5460 .body(body)
5461 .unwrap())
5462 } else {
5463 let chunks = tts
5465 .synthesize_speech(&request.input, language, chunk_frames)
5466 .await
5467 .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
5468
5469 let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
5470 let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
5471
5472 Ok(Response::builder()
5473 .status(200)
5474 .header("content-type", speech_content_type(response_format))
5475 .header("content-length", audio_bytes.len().to_string())
5476 .body(axum::body::Body::from(audio_bytes))
5477 .unwrap())
5478 }
5479}
5480
5481#[derive(Clone, Copy)]
5482enum SpeechOutputFormat {
5483 Wav,
5484 Pcm,
5485}
5486
5487fn speech_output_format(
5488 request: &SpeechRequest,
5489) -> std::result::Result<SpeechOutputFormat, ServerError> {
5490 if request.response_format.eq_ignore_ascii_case("wav") {
5491 Ok(SpeechOutputFormat::Wav)
5492 } else if request.response_format.eq_ignore_ascii_case("pcm") {
5493 Ok(SpeechOutputFormat::Pcm)
5494 } else {
5495 Err(ServerError::unsupported_feature(
5496 "only response_format=wav or response_format=pcm is supported for speech",
5497 Some("response_format"),
5498 ))
5499 }
5500}
5501
5502fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
5503 match format {
5504 SpeechOutputFormat::Wav => "audio/wav",
5505 SpeechOutputFormat::Pcm => "audio/pcm",
5506 }
5507}
5508
5509fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
5510 match format {
5511 SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
5512 SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
5513 }
5514}
5515
5516fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
5518 let num_samples = samples.len();
5519 let data_size = num_samples * 2; let file_size = 44 + data_size;
5521
5522 let mut buf = Vec::with_capacity(file_size);
5523 buf.extend_from_slice(b"RIFF");
5525 buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
5526 buf.extend_from_slice(b"WAVE");
5527 buf.extend_from_slice(b"fmt ");
5529 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());
5533 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");
5538 buf.extend_from_slice(&(data_size as u32).to_le_bytes());
5539 buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
5540 buf
5541}
5542
5543fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
5544 let mut buf = Vec::with_capacity(samples.len() * 2);
5545 for &s in samples {
5546 let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
5547 buf.extend_from_slice(&i16_val.to_le_bytes());
5548 }
5549 buf
5550}
5551
5552async fn models_handler(
5553 State(state): State<AppState>,
5554) -> std::result::Result<Response, ServerError> {
5555 let now = chrono::Utc::now().timestamp() as u64;
5556 let reasoning = state.prompt_template.as_ref().and_then(|template| {
5557 let supported_efforts = template
5558 .reasoning_effort_support
5559 .declared_efforts()
5560 .map(|efforts| efforts.iter().copied().collect());
5561 let thinking =
5562 template
5563 .supports_thinking_control()
5564 .then(|| crate::openai::ModelThinkingCapability {
5565 default_enabled: state
5566 .default_enable_thinking
5567 .unwrap_or(template.reasoning_default_enabled),
5568 });
5569 (supported_efforts.is_some() || thinking.is_some()).then_some(
5570 crate::openai::ModelReasoningCapabilities {
5571 supported_efforts,
5572 thinking,
5573 },
5574 )
5575 });
5576 let data = state
5577 .served_model_registry
5578 .entries()
5579 .iter()
5580 .map(|entry| crate::openai::ModelInfo {
5581 id: entry.public_name().to_string(),
5582 object: "model".to_string(),
5583 created: now,
5584 owned_by: "ferrum".to_string(),
5585 max_model_len: match entry.kind() {
5586 ServedModelKind::Llm => state.llm.as_ref().and_then(|llm| llm.context_capacity()),
5587 _ => None,
5588 },
5589 reasoning: if entry.kind() == ServedModelKind::Llm
5590 && state
5591 .llm
5592 .as_ref()
5593 .is_some_and(|llm| &llm.config().model.model_id == entry.engine_model_id())
5594 {
5595 reasoning.clone()
5596 } else {
5597 None
5598 },
5599 modalities: entry
5600 .kind()
5601 .modalities()
5602 .iter()
5603 .map(ToString::to_string)
5604 .collect(),
5605 permission: vec![],
5606 root: entry.parent_public_name().map(ToString::to_string),
5607 parent: entry.parent_public_name().map(ToString::to_string),
5608 })
5609 .collect();
5610
5611 let models = ModelListResponse {
5612 object: "list".to_string(),
5613 data,
5614 };
5615
5616 Ok(Json(models).into_response())
5617}
5618
5619async fn health_handler(
5620 State(state): State<AppState>,
5621) -> std::result::Result<Response, ServerError> {
5622 let engine_status = state.status().await;
5623 let scheduler_metrics = state.metrics();
5624 let runtime_config = RuntimeConfigSnapshot::capture_current();
5625 let cache_policy = CachePolicy::current();
5626 let engine_cache = state
5627 .llm
5628 .as_ref()
5629 .and_then(|engine| engine.cache_metrics_snapshot());
5630 let execution_attribution = state
5631 .llm
5632 .as_ref()
5633 .and_then(|engine| engine.execution_attribution_snapshot());
5634 let engine_lora = state
5635 .llm
5636 .as_ref()
5637 .and_then(|engine| engine.lora_metrics_snapshot());
5638 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5639 let runtime_admission = match state.llm.as_ref() {
5640 Some(engine) => engine.admission_snapshot(),
5641 None => Ok(None),
5642 };
5643 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5644 Ok(snapshot) => (snapshot.as_ref(), None),
5645 Err(error) => (None, Some(error.to_string())),
5646 };
5647 let admission = admission_health_json(
5648 &engine_status,
5649 &scheduler_metrics,
5650 &auto_config,
5651 runtime_admission_snapshot,
5652 runtime_admission_error.as_deref(),
5653 );
5654
5655 let health = serde_json::json!({
5656 "status": if runtime_admission_error.is_some() { "unhealthy" } else { "healthy" },
5657 "reasoning_protocol": state.prompt_template.as_deref().map(ModelChatTemplate::reasoning_capability).unwrap_or_default(),
5658 "timestamp": chrono::Utc::now().to_rfc3339(),
5659 "version": env!("CARGO_PKG_VERSION"),
5660 "engine": {
5661 "active_requests": engine_status.active_requests,
5662 "queued_requests": engine_status.queued_requests,
5663 },
5664 "scheduler": {
5665 "total_requests": scheduler_metrics.total_requests,
5666 "successful_requests": scheduler_metrics.successful_requests,
5667 "failed_requests": scheduler_metrics.failed_requests,
5668 "throughput_rps": scheduler_metrics.throughput_rps,
5669 "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
5670 "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
5671 "model_execution_time_ms": scheduler_metrics
5672 .performance_breakdown
5673 .model_execution_time_ms,
5674 "iteration_lock_wait_time_ms": scheduler_metrics
5675 .performance_breakdown
5676 .other_overhead_time_ms,
5677 },
5678 "config": runtime_config,
5679 "auto_config": auto_config,
5680 "admission": admission,
5681 "numerical_execution": engine_cache.as_ref().and_then(|snapshot| snapshot.get("numerical_execution")),
5682 "kv_storage": engine_cache.as_ref().and_then(|snapshot| snapshot.get("kv_storage")),
5683 "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
5684 "execution_attribution": execution_attribution,
5685 "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
5686 "enabled": state.served_model_registry.adapter_count() > 0,
5687 "adapter_count": state.served_model_registry.adapter_count() as u64,
5688 "active_cache_bindings": 0u64,
5689 "projection_applications": 0u64,
5690 "position": "startup-routing",
5691 "source": "server-lora-registry",
5692 })),
5693 });
5694
5695 Ok(Json(health).into_response())
5696}
5697
5698async fn metrics_handler(
5700 State(state): State<AppState>,
5701) -> std::result::Result<Response, ServerError> {
5702 let mut body = match PROM_HANDLE.get() {
5703 Some(handle) => handle.render(),
5704 None => "# Prometheus recorder not initialized\n".to_string(),
5705 };
5706 if !body.ends_with('\n') {
5707 body.push('\n');
5708 }
5709 let engine_cache = state
5710 .llm
5711 .as_ref()
5712 .and_then(|engine| engine.cache_metrics_snapshot());
5713 body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
5714 let engine_status = state.status().await;
5715 let scheduler_metrics = state.metrics();
5716 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5717 let runtime_admission = match state.llm.as_ref() {
5718 Some(engine) => engine.admission_snapshot(),
5719 None => Ok(None),
5720 };
5721 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5722 Ok(snapshot) => (snapshot.as_ref(), None),
5723 Err(error) => (None, Some(error.to_string())),
5724 };
5725 let admission = admission_health_json(
5726 &engine_status,
5727 &scheduler_metrics,
5728 &auto_config,
5729 runtime_admission_snapshot,
5730 runtime_admission_error.as_deref(),
5731 );
5732 body.push_str(&admission_prometheus_metrics(&admission));
5733
5734 Ok((
5735 [(
5736 axum::http::header::CONTENT_TYPE,
5737 "text/plain; version=0.0.4; charset=utf-8",
5738 )],
5739 body,
5740 )
5741 .into_response())
5742}
5743
5744async fn root_handler() -> std::result::Result<Response, ServerError> {
5745 let info = serde_json::json!({
5746 "name": "Ferrum Inference Server",
5747 "version": env!("CARGO_PKG_VERSION"),
5748 "api_version": "v1",
5749 "status": "running"
5750 });
5751
5752 Ok(Json(info).into_response())
5753}
5754
5755#[derive(Debug)]
5757enum ServerError {
5758 InvalidRequest {
5759 message: String,
5760 param: Option<String>,
5761 },
5762 UnsupportedFeature {
5763 message: String,
5764 param: Option<String>,
5765 },
5766 InternalError(String),
5767 ContextLengthExceeded(String),
5768 NotImplemented(String),
5769 ServiceUnavailable(String),
5770}
5771
5772impl ServerError {
5773 fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
5774 Self::InvalidRequest {
5775 message: message.into(),
5776 param: param.map(str::to_string),
5777 }
5778 }
5779
5780 fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
5781 Self::UnsupportedFeature {
5782 message: message.into(),
5783 param: param.map(str::to_string),
5784 }
5785 }
5786}
5787
5788impl IntoResponse for ServerError {
5789 fn into_response(self) -> Response {
5790 let code = matches!(&self, ServerError::ContextLengthExceeded(_))
5791 .then(|| "context_length_exceeded".to_owned());
5792 let (status, message, error_type, param) = match self {
5793 ServerError::ContextLengthExceeded(message) => (
5794 AxumStatusCode::BAD_REQUEST,
5795 message,
5796 "invalid_request_error",
5797 None,
5798 ),
5799 ServerError::InvalidRequest { message, param } => (
5800 AxumStatusCode::BAD_REQUEST,
5801 message,
5802 "invalid_request_error",
5803 param,
5804 ),
5805 ServerError::UnsupportedFeature { message, param } => (
5806 AxumStatusCode::BAD_REQUEST,
5807 message,
5808 "invalid_request_error",
5809 param,
5810 ),
5811 ServerError::InternalError(msg) => (
5812 AxumStatusCode::INTERNAL_SERVER_ERROR,
5813 msg,
5814 "internal_server_error",
5815 None,
5816 ),
5817 ServerError::NotImplemented(msg) => (
5818 AxumStatusCode::SERVICE_UNAVAILABLE,
5819 msg,
5820 "service_unavailable_error",
5821 None,
5822 ),
5823 ServerError::ServiceUnavailable(msg) => (
5824 AxumStatusCode::SERVICE_UNAVAILABLE,
5825 msg,
5826 "service_unavailable_error",
5827 None,
5828 ),
5829 };
5830
5831 let error = OpenAiError {
5832 error: OpenAiErrorDetail {
5833 message,
5834 error_type: error_type.to_string(),
5835 param,
5836 code,
5837 },
5838 };
5839
5840 (status, Json(error)).into_response()
5841 }
5842}
5843
5844impl std::fmt::Display for MessageRole {
5845 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5846 match self {
5847 MessageRole::System => write!(f, "system"),
5848 MessageRole::User => write!(f, "user"),
5849 MessageRole::Assistant => write!(f, "assistant"),
5850 MessageRole::Function => write!(f, "function"),
5851 MessageRole::Tool => write!(f, "tool"),
5852 }
5853 }
5854}
5855
5856fn strip_after_stop(text: &str, stops: &[String]) -> String {
5860 let mut first: Option<usize> = None;
5861 for stop in stops {
5862 if stop.is_empty() {
5863 continue;
5864 }
5865 if let Some(idx) = text.find(stop.as_str()) {
5866 first = Some(first.map_or(idx, |current| current.min(idx)));
5867 }
5868 }
5869 match first {
5870 Some(idx) => text[..idx].to_string(),
5871 None => text.to_string(),
5872 }
5873}
5874
5875fn strip_markdown_json_fence(text: &str) -> String {
5878 let trimmed = text.trim();
5879 for prefix in ["```json\n", "```json", "```\n", "```"] {
5881 if let Some(rest) = trimmed.strip_prefix(prefix) {
5882 if let Some(inner) = rest.strip_suffix("```") {
5883 return inner.trim().to_string();
5884 }
5885 }
5886 }
5887 text.to_string()
5888}
5889
5890fn finish_reason_to_string(reason: &FinishReason) -> String {
5892 match reason {
5893 FinishReason::Length => "length".to_string(),
5894 FinishReason::Stop => "stop".to_string(),
5895 FinishReason::EOS => "stop".to_string(),
5896 FinishReason::Cancelled => "cancelled".to_string(),
5897 FinishReason::Error => "error".to_string(),
5898 FinishReason::ContentFilter => "content_filter".to_string(),
5899 }
5900}
5901
5902#[cfg(test)]
5903mod tests {
5904 mod auto_tools_json;
5905 mod engine_stop_contract;
5906 mod gemma_thought;
5907 mod harmony_stops;
5908 mod model_reasoning_metadata;
5909 mod native_tool_stream;
5910 mod reasoning_controls;
5911 mod tool_argument_strictness;
5912 mod tool_length;
5913 use super::*;
5914 use async_trait::async_trait;
5915 use axum::{
5916 body::{to_bytes, Body},
5917 http::{header, Request},
5918 response::Response,
5919 };
5920 use ferrum_interfaces::engine::{
5921 EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
5922 };
5923 use ferrum_types::{
5924 has_unclosed_thinking_block, parse_reasoning_response_started_in_think, EngineConfig,
5925 EngineMetrics, EngineStatus, EngineTokenTimingEvidence, FinishReason,
5926 HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
5927 ModelId, StreamChunk, TokenId, TokenUsage,
5928 };
5929 use futures::{stream, Stream};
5930 use serde_json::{json, Value};
5931 use std::{
5932 collections::HashMap,
5933 pin::Pin,
5934 sync::{atomic::AtomicUsize, Arc, Mutex},
5935 };
5936 use tower::ServiceExt;
5937
5938 #[test]
5939 fn strip_after_stop_removes_first_boundary() {
5940 assert_eq!(
5941 strip_after_stop(
5942 "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
5943 &["END0214Z".to_string()]
5944 ),
5945 "KS0214Z\nS0225\n"
5946 );
5947 }
5948
5949 #[test]
5950 fn gpt_oss_harmony_final_is_split_into_reasoning_and_visible_content() {
5951 let parsed = parse_chat_model_output(
5952 ModelOutputProtocol::HarmonyGptOss,
5953 "<|channel|>analysis<|message|>Reason.<|end|>\
5954 <|start|>assistant<|channel|>final<|message|>Answer.<|return|>",
5955 false,
5956 FinishReason::Stop,
5957 )
5958 .unwrap();
5959 assert_eq!(parsed.visible.content, "Answer.");
5960 assert_eq!(parsed.visible.reasoning.as_deref(), Some("Reason."));
5961 assert!(parsed.harmony_response.is_none());
5962 }
5963
5964 #[test]
5965 fn gpt_oss_harmony_tool_call_becomes_openai_structured_response() {
5966 let parsed = parse_chat_model_output(
5967 ModelOutputProtocol::HarmonyGptOss,
5968 "<|channel|>analysis<|message|>Need weather.<|end|>\
5969 <|start|>assistant<|channel|>commentary to=functions.weather\
5970 <|constrain|>json<|message|>{\"city\":\"Paris\"}<|call|>",
5971 false,
5972 FinishReason::Stop,
5973 )
5974 .unwrap();
5975 let response = parsed.harmony_response.unwrap();
5976 assert_eq!(response.finish_reason.as_deref(), Some("tool_calls"));
5977 assert_eq!(response.message.tool_calls.len(), 1);
5978 assert_eq!(response.message.tool_calls[0].function.name, "weather");
5979 assert_eq!(
5980 response.message.tool_calls[0].function.arguments,
5981 "{\"city\":\"Paris\"}"
5982 );
5983 assert!(response.message.tool_calls[0].id.starts_with("call_"));
5984 }
5985
5986 #[test]
5987 fn gpt_oss_harmony_accepts_missing_text_terminal_only_for_explicit_truncation() {
5988 let output = "<|channel|>analysis<|message|>Still reasoning";
5989 for finish_reason in [FinishReason::Stop, FinishReason::Length] {
5990 let parsed = parse_chat_model_output(
5991 ModelOutputProtocol::HarmonyGptOss,
5992 output,
5993 false,
5994 finish_reason,
5995 )
5996 .unwrap();
5997 assert_eq!(parsed.visible.reasoning.as_deref(), Some("Still reasoning"));
5998 assert!(parsed.visible.content.is_empty());
5999 }
6000 for finish_reason in [
6001 FinishReason::EOS,
6002 FinishReason::Cancelled,
6003 FinishReason::Error,
6004 FinishReason::ContentFilter,
6005 ] {
6006 assert!(parse_chat_model_output(
6007 ModelOutputProtocol::HarmonyGptOss,
6008 output,
6009 false,
6010 finish_reason,
6011 )
6012 .is_err());
6013 }
6014 }
6015
6016 #[tokio::test]
6017 async fn stop_drains_running_server_and_shuts_down_loaded_engine_once() {
6018 let engine = Arc::new(StubLlm::new("ok"));
6019 let server = Arc::new(AxumServer::from_llm(engine.clone()));
6020 let config = ServerConfig {
6021 host: "127.0.0.1".to_string(),
6022 port: 0,
6023 ..ServerConfig::default()
6024 };
6025 let server_task = {
6026 let server = Arc::clone(&server);
6027 tokio::spawn(async move { server.start(&config).await })
6028 };
6029 tokio::time::timeout(std::time::Duration::from_secs(1), async {
6030 while !server.is_running() {
6031 tokio::task::yield_now().await;
6032 }
6033 })
6034 .await
6035 .unwrap();
6036
6037 server
6038 .stop(std::time::Duration::from_secs(1))
6039 .await
6040 .unwrap();
6041 server
6042 .stop(std::time::Duration::from_secs(1))
6043 .await
6044 .unwrap();
6045 server_task.await.unwrap().unwrap();
6046
6047 assert_eq!(engine.shutdown_count.load(Ordering::Acquire), 1);
6048 assert!(!server.is_running());
6049 }
6050
6051 struct StubLlm {
6052 config: EngineConfig,
6053 context_capacity: Option<usize>,
6054 text: String,
6055 stream_chunks: Option<Vec<String>>,
6056 stream_final_chunk_separate: bool,
6057 stream_tail_without_token: bool,
6058 stream_usage: Option<TokenUsage>,
6059 api_response: Option<ferrum_types::ApiResponse>,
6060 finish_reason: FinishReason,
6061 execution_attribution: Option<Value>,
6062 lora_metrics: Option<Value>,
6063 pending_stream_drop_notify: Option<Arc<Notify>>,
6064 stream_after_first_gate: Option<Arc<StreamGate>>,
6065 stream_terminal_error: bool,
6066 shutdown_count: AtomicUsize,
6067 }
6068
6069 impl StubLlm {
6070 fn new(text: &str) -> Self {
6071 let mut config = EngineConfig::default();
6072 config.model.model_id = ModelId::new("stub-model");
6073 Self {
6074 config,
6075 text: text.to_string(),
6076 context_capacity: None,
6077 stream_chunks: None,
6078 stream_final_chunk_separate: false,
6079 stream_tail_without_token: false,
6080 stream_usage: Some(TokenUsage::new(5, 1)),
6081 api_response: None,
6082 finish_reason: FinishReason::EOS,
6083 execution_attribution: None,
6084 lora_metrics: None,
6085 pending_stream_drop_notify: None,
6086 stream_after_first_gate: None,
6087 stream_terminal_error: false,
6088 shutdown_count: AtomicUsize::new(0),
6089 }
6090 }
6091
6092 fn without_stream_usage(text: &str) -> Self {
6093 Self {
6094 stream_usage: None,
6095 ..Self::new(text)
6096 }
6097 }
6098
6099 fn with_stream_chunks(chunks: &[&str]) -> Self {
6100 Self {
6101 text: chunks.concat(),
6102 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
6103 stream_usage: Some(TokenUsage::new(5, chunks.len())),
6104 ..Self::new("")
6105 }
6106 }
6107
6108 fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
6109 Self {
6110 text: chunks.concat(),
6111 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
6112 stream_final_chunk_separate: true,
6113 stream_usage: Some(TokenUsage::new(5, chunks.len())),
6114 ..Self::new("")
6115 }
6116 }
6117
6118 fn with_tokenless_tail(chunks: &[&str]) -> Self {
6119 Self {
6120 stream_tail_without_token: true,
6121 ..Self::with_separate_final_stream_chunk(chunks)
6122 }
6123 }
6124
6125 fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
6126 Self {
6127 api_response: Some(api_response),
6128 ..Self::new(text)
6129 }
6130 }
6131
6132 fn with_api_response_and_finish_reason(
6133 text: &str,
6134 api_response: ferrum_types::ApiResponse,
6135 finish_reason: FinishReason,
6136 ) -> Self {
6137 Self {
6138 api_response: Some(api_response),
6139 finish_reason,
6140 ..Self::new(text)
6141 }
6142 }
6143
6144 fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
6145 Self {
6146 lora_metrics: Some(lora_metrics),
6147 ..Self::new(text)
6148 }
6149 }
6150
6151 fn with_execution_attribution(text: &str, execution_attribution: Value) -> Self {
6152 Self {
6153 execution_attribution: Some(execution_attribution),
6154 ..Self::new(text)
6155 }
6156 }
6157
6158 fn with_pending_stream(drop_notify: Arc<Notify>) -> Self {
6159 Self {
6160 pending_stream_drop_notify: Some(drop_notify),
6161 ..Self::new("")
6162 }
6163 }
6164 }
6165
6166 struct PendingDropStream {
6167 drop_notify: Arc<Notify>,
6168 }
6169
6170 #[derive(Default)]
6171 struct StreamGate {
6172 entered: Notify,
6173 resume: Notify,
6174 }
6175
6176 impl Stream for PendingDropStream {
6177 type Item = ferrum_types::Result<StreamChunk>;
6178
6179 fn poll_next(
6180 self: Pin<&mut Self>,
6181 _cx: &mut std::task::Context<'_>,
6182 ) -> std::task::Poll<Option<Self::Item>> {
6183 std::task::Poll::Pending
6184 }
6185 }
6186
6187 impl Drop for PendingDropStream {
6188 fn drop(&mut self) {
6189 self.drop_notify.notify_one();
6190 }
6191 }
6192
6193 struct StubEmbed {
6194 config: EngineConfig,
6195 }
6196
6197 impl StubEmbed {
6198 fn new() -> Self {
6199 let mut config = EngineConfig::default();
6200 config.model.model_id = ModelId::new("stub-embed");
6201 Self { config }
6202 }
6203 }
6204
6205 struct StubTranscribe {
6206 config: EngineConfig,
6207 }
6208
6209 impl StubTranscribe {
6210 fn new() -> Self {
6211 let mut config = EngineConfig::default();
6212 config.model.model_id = ModelId::new("stub-transcribe");
6213 Self { config }
6214 }
6215 }
6216
6217 struct StubTts {
6218 config: EngineConfig,
6219 }
6220
6221 impl StubTts {
6222 fn new() -> Self {
6223 let mut config = EngineConfig::default();
6224 config.model.model_id = ModelId::new("stub-tts");
6225 Self { config }
6226 }
6227 }
6228
6229 struct FailingLlm {
6230 config: EngineConfig,
6231 fail_after_stream_start: bool,
6232 infer_failure: ferrum_types::FerrumError,
6233 stream_start_failure: ferrum_types::FerrumError,
6234 stream_chunk_failure: ferrum_types::FerrumError,
6235 }
6236
6237 impl FailingLlm {
6238 fn new() -> Self {
6239 let mut config = EngineConfig::default();
6240 config.model.model_id = ModelId::new("failing-model");
6241 Self {
6242 config,
6243 fail_after_stream_start: false,
6244 infer_failure: ferrum_types::FerrumError::internal("stub generation failed"),
6245 stream_start_failure: ferrum_types::FerrumError::internal("stub stream failed"),
6246 stream_chunk_failure: ferrum_types::FerrumError::internal(
6247 "stub stream chunk failed",
6248 ),
6249 }
6250 }
6251
6252 fn after_stream_start() -> Self {
6253 Self {
6254 fail_after_stream_start: true,
6255 ..Self::new()
6256 }
6257 }
6258
6259 fn resource_exhausted() -> Self {
6260 let failure = ferrum_types::FerrumError::resource_exhausted(
6261 "admission capacity exhausted while reserving request resources",
6262 );
6263 Self {
6264 infer_failure: failure.clone(),
6265 stream_start_failure: failure.clone(),
6266 stream_chunk_failure: failure,
6267 ..Self::new()
6268 }
6269 }
6270
6271 fn context_length_exceeded() -> Self {
6272 let failure = ferrum_types::FerrumError::ContextLengthExceeded {
6273 capacity: 512,
6274 input_tokens: 500,
6275 output_tokens: 100,
6276 };
6277 Self {
6278 infer_failure: failure.clone(),
6279 stream_start_failure: failure,
6280 ..Self::new()
6281 }
6282 }
6283 }
6284
6285 struct CapturingLlm {
6286 config: EngineConfig,
6287 last_request: Mutex<Option<InferenceRequest>>,
6288 }
6289
6290 impl CapturingLlm {
6291 fn new() -> Self {
6292 let mut config = EngineConfig::default();
6293 config.model.model_id = ModelId::new("qwen3");
6294 Self {
6295 config,
6296 last_request: Mutex::new(None),
6297 }
6298 }
6299
6300 fn last_request(&self) -> InferenceRequest {
6301 self.last_request
6302 .lock()
6303 .expect("capture lock")
6304 .clone()
6305 .expect("request captured")
6306 }
6307
6308 fn has_captured_request(&self) -> bool {
6309 self.last_request.lock().expect("capture lock").is_some()
6310 }
6311 }
6312
6313 #[async_trait]
6314 impl InferenceEngine for StubLlm {
6315 async fn status(&self) -> EngineStatus {
6316 EngineStatus {
6317 is_ready: true,
6318 loaded_models: vec![self.config.model.model_id.clone()],
6319 active_requests: 0,
6320 queued_requests: 0,
6321 memory_usage: MemoryUsage {
6322 total_bytes: 0,
6323 used_bytes: 0,
6324 free_bytes: 0,
6325 gpu_memory_bytes: None,
6326 cpu_memory_bytes: None,
6327 cache_memory_bytes: 0,
6328 utilization_percent: 0.0,
6329 },
6330 uptime_seconds: 0,
6331 last_heartbeat: chrono::Utc::now(),
6332 version: "test".to_string(),
6333 }
6334 }
6335
6336 async fn shutdown(&self) -> ferrum_types::Result<()> {
6337 self.shutdown_count.fetch_add(1, Ordering::AcqRel);
6338 Ok(())
6339 }
6340
6341 fn config(&self) -> &EngineConfig {
6342 &self.config
6343 }
6344
6345 fn metrics(&self) -> EngineMetrics {
6346 EngineMetrics::default()
6347 }
6348
6349 async fn health_check(&self) -> EngineHealthStatus {
6350 EngineHealthStatus::healthy()
6351 }
6352
6353 fn execution_attribution_snapshot(&self) -> Option<Value> {
6354 self.execution_attribution.clone()
6355 }
6356
6357 fn lora_metrics_snapshot(&self) -> Option<Value> {
6358 self.lora_metrics.clone()
6359 }
6360 }
6361
6362 #[async_trait]
6363 impl InferenceEngine for StubEmbed {
6364 async fn status(&self) -> EngineStatus {
6365 EngineStatus {
6366 is_ready: true,
6367 loaded_models: vec![self.config.model.model_id.clone()],
6368 active_requests: 0,
6369 queued_requests: 0,
6370 memory_usage: MemoryUsage {
6371 total_bytes: 0,
6372 used_bytes: 0,
6373 free_bytes: 0,
6374 gpu_memory_bytes: None,
6375 cpu_memory_bytes: None,
6376 cache_memory_bytes: 0,
6377 utilization_percent: 0.0,
6378 },
6379 uptime_seconds: 0,
6380 last_heartbeat: chrono::Utc::now(),
6381 version: "test".to_string(),
6382 }
6383 }
6384
6385 async fn shutdown(&self) -> ferrum_types::Result<()> {
6386 Ok(())
6387 }
6388
6389 fn config(&self) -> &EngineConfig {
6390 &self.config
6391 }
6392
6393 fn metrics(&self) -> EngineMetrics {
6394 EngineMetrics::default()
6395 }
6396
6397 async fn health_check(&self) -> EngineHealthStatus {
6398 EngineHealthStatus::healthy()
6399 }
6400 }
6401
6402 #[async_trait]
6403 impl EmbedEngine for StubEmbed {
6404 async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
6405 Ok(vec![text.len() as f32, 1.0, 0.0])
6406 }
6407
6408 async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
6409 Ok(vec![image.len() as f32, 0.0, 1.0])
6410 }
6411
6412 fn embedding_dim(&self) -> usize {
6413 3
6414 }
6415 }
6416
6417 #[async_trait]
6418 impl InferenceEngine for StubTranscribe {
6419 async fn status(&self) -> EngineStatus {
6420 EngineStatus {
6421 is_ready: true,
6422 loaded_models: vec![self.config.model.model_id.clone()],
6423 active_requests: 0,
6424 queued_requests: 0,
6425 memory_usage: MemoryUsage {
6426 total_bytes: 0,
6427 used_bytes: 0,
6428 free_bytes: 0,
6429 gpu_memory_bytes: None,
6430 cpu_memory_bytes: None,
6431 cache_memory_bytes: 0,
6432 utilization_percent: 0.0,
6433 },
6434 uptime_seconds: 0,
6435 last_heartbeat: chrono::Utc::now(),
6436 version: "test".to_string(),
6437 }
6438 }
6439
6440 async fn shutdown(&self) -> ferrum_types::Result<()> {
6441 Ok(())
6442 }
6443
6444 fn config(&self) -> &EngineConfig {
6445 &self.config
6446 }
6447
6448 fn metrics(&self) -> EngineMetrics {
6449 EngineMetrics::default()
6450 }
6451
6452 async fn health_check(&self) -> EngineHealthStatus {
6453 EngineHealthStatus::healthy()
6454 }
6455 }
6456
6457 #[async_trait]
6458 impl TranscribeEngine for StubTranscribe {
6459 async fn transcribe_file(
6460 &self,
6461 path: &str,
6462 language: Option<&str>,
6463 ) -> ferrum_types::Result<String> {
6464 Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
6465 }
6466
6467 async fn transcribe_bytes(
6468 &self,
6469 data: &[u8],
6470 language: Option<&str>,
6471 ) -> ferrum_types::Result<String> {
6472 Ok(format!(
6473 "bytes:{}:{}",
6474 data.len(),
6475 language.unwrap_or("auto")
6476 ))
6477 }
6478 }
6479
6480 #[async_trait]
6481 impl InferenceEngine for StubTts {
6482 async fn status(&self) -> EngineStatus {
6483 EngineStatus {
6484 is_ready: true,
6485 loaded_models: vec![self.config.model.model_id.clone()],
6486 active_requests: 0,
6487 queued_requests: 0,
6488 memory_usage: MemoryUsage {
6489 total_bytes: 0,
6490 used_bytes: 0,
6491 free_bytes: 0,
6492 gpu_memory_bytes: None,
6493 cpu_memory_bytes: None,
6494 cache_memory_bytes: 0,
6495 utilization_percent: 0.0,
6496 },
6497 uptime_seconds: 0,
6498 last_heartbeat: chrono::Utc::now(),
6499 version: "test".to_string(),
6500 }
6501 }
6502
6503 async fn shutdown(&self) -> ferrum_types::Result<()> {
6504 Ok(())
6505 }
6506
6507 fn config(&self) -> &EngineConfig {
6508 &self.config
6509 }
6510
6511 fn metrics(&self) -> EngineMetrics {
6512 EngineMetrics::default()
6513 }
6514
6515 async fn health_check(&self) -> EngineHealthStatus {
6516 EngineHealthStatus::healthy()
6517 }
6518 }
6519
6520 #[async_trait]
6521 impl TtsEngine for StubTts {
6522 async fn synthesize_speech(
6523 &self,
6524 _text: &str,
6525 _language: Option<&str>,
6526 _chunk_frames: usize,
6527 ) -> ferrum_types::Result<Vec<Vec<f32>>> {
6528 Ok(vec![vec![0.0, 0.5, -0.5]])
6529 }
6530
6531 fn tts_sample_rate(&self) -> u32 {
6532 16_000
6533 }
6534 }
6535
6536 #[async_trait]
6537 impl InferenceEngine for FailingLlm {
6538 async fn status(&self) -> EngineStatus {
6539 EngineStatus {
6540 is_ready: true,
6541 loaded_models: vec![self.config.model.model_id.clone()],
6542 active_requests: 0,
6543 queued_requests: 0,
6544 memory_usage: MemoryUsage {
6545 total_bytes: 0,
6546 used_bytes: 0,
6547 free_bytes: 0,
6548 gpu_memory_bytes: None,
6549 cpu_memory_bytes: None,
6550 cache_memory_bytes: 0,
6551 utilization_percent: 0.0,
6552 },
6553 uptime_seconds: 0,
6554 last_heartbeat: chrono::Utc::now(),
6555 version: "test".to_string(),
6556 }
6557 }
6558
6559 async fn shutdown(&self) -> ferrum_types::Result<()> {
6560 Ok(())
6561 }
6562
6563 fn config(&self) -> &EngineConfig {
6564 &self.config
6565 }
6566
6567 fn metrics(&self) -> EngineMetrics {
6568 EngineMetrics::default()
6569 }
6570
6571 async fn health_check(&self) -> EngineHealthStatus {
6572 EngineHealthStatus::healthy()
6573 }
6574 }
6575
6576 #[async_trait]
6577 impl InferenceEngine for CapturingLlm {
6578 async fn status(&self) -> EngineStatus {
6579 EngineStatus {
6580 is_ready: true,
6581 loaded_models: vec![self.config.model.model_id.clone()],
6582 active_requests: 0,
6583 queued_requests: 0,
6584 memory_usage: MemoryUsage {
6585 total_bytes: 0,
6586 used_bytes: 0,
6587 free_bytes: 0,
6588 gpu_memory_bytes: None,
6589 cpu_memory_bytes: None,
6590 cache_memory_bytes: 0,
6591 utilization_percent: 0.0,
6592 },
6593 uptime_seconds: 0,
6594 last_heartbeat: chrono::Utc::now(),
6595 version: "test".to_string(),
6596 }
6597 }
6598
6599 async fn shutdown(&self) -> ferrum_types::Result<()> {
6600 Ok(())
6601 }
6602
6603 fn config(&self) -> &EngineConfig {
6604 &self.config
6605 }
6606
6607 fn metrics(&self) -> EngineMetrics {
6608 EngineMetrics::default()
6609 }
6610
6611 async fn health_check(&self) -> EngineHealthStatus {
6612 EngineHealthStatus::healthy()
6613 }
6614 }
6615
6616 fn stub_execution_evidence(
6617 request: &InferenceRequest,
6618 output_token_count: usize,
6619 ) -> Option<InferenceExecutionEvidence> {
6620 let requested = &request.evidence_request;
6621 if !requested.capture_prompt_token_ids && !requested.capture_engine_token_timing {
6622 return None;
6623 }
6624 Some(InferenceExecutionEvidence {
6625 prompt_token_ids: requested
6626 .capture_prompt_token_ids
6627 .then(|| vec![TokenId::new(101), TokenId::new(202), TokenId::new(303)])
6628 .unwrap_or_default(),
6629 output_token_ids: (0..output_token_count)
6630 .map(|index| TokenId::new(11 + index as u32))
6631 .collect(),
6632 engine_token_timing: requested.capture_engine_token_timing.then(|| {
6633 EngineTokenTimingEvidence {
6634 clock_source: "rust_std_instant".to_string(),
6635 wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
6636 wall_anchor_max_error_nanos: 500,
6637 decode_ready_nanos_since_request_start: Some(1_000_000),
6638 token_commit_nanos_since_request_start: (1..=output_token_count)
6639 .map(|ordinal| ordinal as u64 * 1_000_000)
6640 .collect(),
6641 decode_stage_intervals: Vec::new(),
6642 }
6643 }),
6644 })
6645 }
6646
6647 #[async_trait]
6648 impl LlmInferenceEngine for StubLlm {
6649 fn context_capacity(&self) -> Option<usize> {
6650 self.context_capacity
6651 }
6652
6653 async fn infer(
6654 &self,
6655 request: InferenceRequest,
6656 ) -> ferrum_types::Result<InferenceResponse> {
6657 let execution_evidence = stub_execution_evidence(&request, 2);
6658 Ok(InferenceResponse {
6659 request_id: request.id,
6660 text: self.text.clone(),
6661 tokens: vec![TokenId::new(11), TokenId::new(12)],
6662 finish_reason: self.finish_reason,
6663 usage: TokenUsage::new(7, 2),
6664 latency_ms: 1,
6665 created_at: chrono::Utc::now(),
6666 metadata: HashMap::new(),
6667 api_response: self.api_response.clone(),
6668 execution_evidence,
6669 })
6670 }
6671
6672 async fn infer_stream(
6673 &self,
6674 request: InferenceRequest,
6675 ) -> ferrum_types::Result<
6676 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6677 > {
6678 if let Some(drop_notify) = self.pending_stream_drop_notify.as_ref() {
6679 return Ok(Box::pin(PendingDropStream {
6680 drop_notify: Arc::clone(drop_notify),
6681 }));
6682 }
6683 if let Some(chunks) = &self.stream_chunks {
6684 let completion_token_count = self
6685 .stream_usage
6686 .as_ref()
6687 .map(|usage| usage.completion_tokens)
6688 .unwrap_or(chunks.len());
6689 let execution_evidence = stub_execution_evidence(&request, completion_token_count);
6690 let request_id = request.id;
6691 let mut stream_chunks = Vec::with_capacity(
6692 chunks.len() + usize::from(self.stream_final_chunk_separate),
6693 );
6694 let last = chunks.len().saturating_sub(1);
6695 for (index, text) in chunks.iter().enumerate() {
6696 let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
6697 stream_chunks.push(Ok(StreamChunk {
6698 request_id: request_id.clone(),
6699 text: text.clone(),
6700 token: (!(self.stream_tail_without_token && index == last))
6701 .then_some(TokenId::new(11 + index as u32)),
6702 finish_reason: is_final_text_chunk.then_some(self.finish_reason),
6703 usage: is_final_text_chunk
6704 .then(|| self.stream_usage.clone())
6705 .flatten(),
6706 created_at: chrono::Utc::now(),
6707 metadata: HashMap::new(),
6708 api_response: is_final_text_chunk
6709 .then(|| self.api_response.clone())
6710 .flatten(),
6711 execution_evidence: is_final_text_chunk
6712 .then(|| execution_evidence.clone())
6713 .flatten(),
6714 }));
6715 }
6716 if self.stream_final_chunk_separate {
6717 stream_chunks.push(Ok(StreamChunk {
6718 request_id,
6719 text: String::new(),
6720 token: None,
6721 finish_reason: Some(self.finish_reason),
6722 usage: self.stream_usage.clone(),
6723 created_at: chrono::Utc::now(),
6724 metadata: HashMap::new(),
6725 api_response: self.api_response.clone(),
6726 execution_evidence,
6727 }));
6728 }
6729 if self.stream_terminal_error {
6730 *stream_chunks.last_mut().expect("nonempty fixture stream") = Err(
6731 ferrum_types::FerrumError::internal("fixture generation failed"),
6732 );
6733 }
6734 if let Some(gate) = self.stream_after_first_gate.clone() {
6735 return Ok(Box::pin(stream::unfold(
6736 (stream_chunks.into_iter().enumerate(), gate),
6737 |(mut chunks, gate)| async move {
6738 let (index, chunk) = chunks.next()?;
6739 if index == 1 {
6740 gate.entered.notify_one();
6741 gate.resume.notified().await;
6742 }
6743 Some((chunk, (chunks, gate)))
6744 },
6745 )));
6746 }
6747 return Ok(Box::pin(stream::iter(stream_chunks)));
6748 }
6749
6750 let execution_evidence = stub_execution_evidence(&request, 1);
6751 let chunk = StreamChunk {
6752 request_id: request.id,
6753 text: self.text.clone(),
6754 token: Some(TokenId::new(11)),
6755 finish_reason: Some(self.finish_reason),
6756 usage: self.stream_usage.clone(),
6757 created_at: chrono::Utc::now(),
6758 metadata: HashMap::new(),
6759 api_response: self.api_response.clone(),
6760 execution_evidence,
6761 };
6762 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6763 }
6764 }
6765
6766 #[async_trait]
6767 impl LlmInferenceEngine for FailingLlm {
6768 async fn infer(
6769 &self,
6770 _request: InferenceRequest,
6771 ) -> ferrum_types::Result<InferenceResponse> {
6772 Err(self.infer_failure.clone())
6773 }
6774
6775 async fn infer_stream(
6776 &self,
6777 request: InferenceRequest,
6778 ) -> ferrum_types::Result<
6779 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6780 > {
6781 if self.fail_after_stream_start {
6782 let _request_id = request.id;
6783 return Ok(Box::pin(stream::iter(vec![Err(self
6784 .stream_chunk_failure
6785 .clone())])));
6786 }
6787 Err(self.stream_start_failure.clone())
6788 }
6789 }
6790
6791 #[async_trait]
6792 impl LlmInferenceEngine for CapturingLlm {
6793 async fn infer(
6794 &self,
6795 request: InferenceRequest,
6796 ) -> ferrum_types::Result<InferenceResponse> {
6797 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6798 Ok(InferenceResponse {
6799 request_id: request.id,
6800 text: "captured".to_string(),
6801 tokens: vec![TokenId::new(21)],
6802 finish_reason: FinishReason::Stop,
6803 usage: TokenUsage::new(9, 1),
6804 latency_ms: 1,
6805 created_at: chrono::Utc::now(),
6806 metadata: HashMap::new(),
6807 api_response: None,
6808 execution_evidence: None,
6809 })
6810 }
6811
6812 async fn infer_stream(
6813 &self,
6814 request: InferenceRequest,
6815 ) -> ferrum_types::Result<
6816 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6817 > {
6818 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6819 let chunk = StreamChunk {
6820 request_id: request.id,
6821 text: "captured".to_string(),
6822 token: Some(TokenId::new(21)),
6823 finish_reason: Some(FinishReason::Stop),
6824 usage: Some(TokenUsage::new(9, 1)),
6825 created_at: chrono::Utc::now(),
6826 metadata: HashMap::new(),
6827 api_response: None,
6828 execution_evidence: None,
6829 };
6830 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6831 }
6832 }
6833
6834 fn state_with_stub(text: &str) -> AppState {
6835 AppState::default().with_llm(Arc::new(StubLlm::new(text)))
6836 }
6837
6838 fn router_with_stub(text: &str) -> Router {
6839 AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
6840 }
6841
6842 fn router_with_stub_and_template(text: &str, template: ModelChatTemplate) -> Router {
6843 AxumServer::from_llm(Arc::new(StubLlm::new(text)))
6844 .with_prompt_template(Some(template))
6845 .build_router()
6846 }
6847
6848 fn router_with_stub_and_request_dump_dir(text: &str, request_dump_dir: PathBuf) -> Router {
6849 AxumServer::from_state(
6850 AppState::default()
6851 .with_llm(Arc::new(StubLlm::new(text)))
6852 .with_request_dump_dir(Some(request_dump_dir)),
6853 )
6854 .build_router()
6855 }
6856
6857 fn router_with_stub_request_dump_and_profile(
6858 text: &str,
6859 request_dump_dir: PathBuf,
6860 profile_jsonl: PathBuf,
6861 ) -> Router {
6862 AxumServer::from_state(
6863 AppState::default()
6864 .with_llm(Arc::new(StubLlm::new(text)))
6865 .with_request_dump_dir(Some(request_dump_dir))
6866 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6867 .with_profile_jsonl(Some(profile_jsonl)),
6868 )
6869 .build_router()
6870 }
6871
6872 fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
6873 AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
6874 }
6875
6876 fn router_with_stub_finish_reason(text: &str, finish_reason: FinishReason) -> Router {
6877 AxumServer::from_llm(Arc::new(StubLlm {
6878 finish_reason,
6879 ..StubLlm::new(text)
6880 }))
6881 .build_router()
6882 }
6883
6884 fn router_with_stub_stream_chunks_and_request_dump_dir(
6885 chunks: &[&str],
6886 request_dump_dir: PathBuf,
6887 ) -> Router {
6888 AxumServer::from_state(
6889 AppState::default()
6890 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6891 .with_request_dump_dir(Some(request_dump_dir)),
6892 )
6893 .build_router()
6894 }
6895
6896 fn router_with_stub_stream_request_dump_and_profile(
6897 chunks: &[&str],
6898 request_dump_dir: PathBuf,
6899 profile_jsonl: PathBuf,
6900 ) -> Router {
6901 AxumServer::from_state(
6902 AppState::default()
6903 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6904 .with_request_dump_dir(Some(request_dump_dir))
6905 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6906 .with_profile_jsonl(Some(profile_jsonl)),
6907 )
6908 .build_router()
6909 }
6910
6911 fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
6912 AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
6913 .build_router()
6914 }
6915
6916 fn router_with_stub_api_response(
6917 text: &str,
6918 api_response: ferrum_types::ApiResponse,
6919 ) -> Router {
6920 AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
6921 .build_router()
6922 }
6923
6924 fn router_with_stub_api_response_and_finish_reason(
6925 text: &str,
6926 api_response: ferrum_types::ApiResponse,
6927 finish_reason: FinishReason,
6928 ) -> Router {
6929 AxumServer::from_llm(Arc::new(StubLlm::with_api_response_and_finish_reason(
6930 text,
6931 api_response,
6932 finish_reason,
6933 )))
6934 .build_router()
6935 }
6936
6937 fn weather_tool_api_response() -> ferrum_types::ApiResponse {
6938 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6939 message: ferrum_types::ApiChatMessage {
6940 role: ferrum_types::ApiMessageRole::Assistant,
6941 content: String::new(),
6942 name: None,
6943 tool_calls: vec![ferrum_types::ApiToolCall {
6944 id: "call_1".to_string(),
6945 tool_type: "function".to_string(),
6946 function: ferrum_types::ApiFunctionCall {
6947 name: "weather".to_string(),
6948 arguments: "{\"city\":\"Paris\"}".to_string(),
6949 },
6950 }],
6951 tool_call_id: None,
6952 function_call: None,
6953 },
6954 finish_reason: Some("tool_calls".to_string()),
6955 })
6956 }
6957
6958 fn weather_tool_api_response_with_commentary() -> ferrum_types::ApiResponse {
6959 let mut response = weather_tool_api_response();
6960 let ferrum_types::ApiResponse::Chat(chat) = &mut response else {
6961 unreachable!("weather response is chat")
6962 };
6963 chat.message.content = "I will check.".to_string();
6964 response
6965 }
6966
6967 fn namespaced_tool_api_response() -> ferrum_types::ApiResponse {
6968 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6969 message: ferrum_types::ApiChatMessage {
6970 role: ferrum_types::ApiMessageRole::Assistant,
6971 content: String::new(),
6972 name: None,
6973 tool_calls: vec![ferrum_types::ApiToolCall {
6974 id: "call_ns_1".to_string(),
6975 tool_type: "function".to_string(),
6976 function: ferrum_types::ApiFunctionCall {
6977 name: "collaboration__wait_agent".to_string(),
6978 arguments: "{}".to_string(),
6979 },
6980 }],
6981 tool_call_id: None,
6982 function_call: None,
6983 },
6984 finish_reason: Some("tool_calls".to_string()),
6985 })
6986 }
6987
6988 fn two_tool_api_response() -> ferrum_types::ApiResponse {
6989 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6990 message: ferrum_types::ApiChatMessage {
6991 role: ferrum_types::ApiMessageRole::Assistant,
6992 content: String::new(),
6993 name: None,
6994 tool_calls: vec![
6995 ferrum_types::ApiToolCall {
6996 id: "call_1".to_string(),
6997 tool_type: "function".to_string(),
6998 function: ferrum_types::ApiFunctionCall {
6999 name: "weather".to_string(),
7000 arguments: "{}".to_string(),
7001 },
7002 },
7003 ferrum_types::ApiToolCall {
7004 id: "call_2".to_string(),
7005 tool_type: "function".to_string(),
7006 function: ferrum_types::ApiFunctionCall {
7007 name: "clock".to_string(),
7008 arguments: "{}".to_string(),
7009 },
7010 },
7011 ],
7012 tool_call_id: None,
7013 function_call: None,
7014 },
7015 finish_reason: Some("tool_calls".to_string()),
7016 })
7017 }
7018
7019 fn router_with_stub_without_stream_usage(text: &str) -> Router {
7020 AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
7021 }
7022
7023 fn router_without_llm() -> Router {
7024 AxumServer::from_state(AppState::default()).build_router()
7025 }
7026
7027 fn router_with_failing_llm() -> Router {
7028 AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
7029 }
7030
7031 fn router_with_failing_llm_and_request_dump_dir(request_dump_dir: PathBuf) -> Router {
7032 AxumServer::from_state(
7033 AppState::default()
7034 .with_llm(Arc::new(FailingLlm::new()))
7035 .with_request_dump_dir(Some(request_dump_dir)),
7036 )
7037 .build_router()
7038 }
7039
7040 fn router_with_failing_llm_request_dump_and_profile(
7041 request_dump_dir: PathBuf,
7042 profile_jsonl: PathBuf,
7043 ) -> Router {
7044 AxumServer::from_state(
7045 AppState::default()
7046 .with_llm(Arc::new(FailingLlm::new()))
7047 .with_request_dump_dir(Some(request_dump_dir))
7048 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
7049 .with_profile_jsonl(Some(profile_jsonl)),
7050 )
7051 .build_router()
7052 }
7053
7054 fn router_with_resource_exhausted_llm_and_request_dump_dir(
7055 request_dump_dir: PathBuf,
7056 ) -> Router {
7057 AxumServer::from_state(
7058 AppState::default()
7059 .with_llm(Arc::new(FailingLlm::resource_exhausted()))
7060 .with_request_dump_dir(Some(request_dump_dir)),
7061 )
7062 .build_router()
7063 }
7064
7065 fn router_with_stream_chunk_failing_llm() -> Router {
7066 AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
7067 }
7068
7069 fn router_with_stream_chunk_failing_llm_and_request_dump_dir(
7070 request_dump_dir: PathBuf,
7071 ) -> Router {
7072 AxumServer::from_state(
7073 AppState::default()
7074 .with_llm(Arc::new(FailingLlm::after_stream_start()))
7075 .with_request_dump_dir(Some(request_dump_dir)),
7076 )
7077 .build_router()
7078 }
7079
7080 fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
7081 let engine = Arc::new(CapturingLlm::new());
7082 let registry = ServedModelRegistry::try_new(
7083 "qwen3",
7084 ServedModelKind::Llm,
7085 vec![
7086 "qwen3".to_string(),
7087 "stub-model".to_string(),
7088 "served-alias".to_string(),
7089 ],
7090 vec![],
7091 )
7092 .unwrap();
7093 let router = AxumServer::from_llm(engine.clone())
7094 .with_served_model_registry(registry)
7095 .build_router();
7096 (router, engine)
7097 }
7098
7099 fn unique_request_dump_dir(test_name: &str) -> PathBuf {
7100 let path =
7101 std::env::temp_dir().join(format!("ferrum-server-{test_name}-{}", Uuid::new_v4()));
7102 fs::create_dir_all(&path).expect("create request dump dir");
7103 path
7104 }
7105
7106 fn unique_profile_jsonl(test_name: &str) -> PathBuf {
7107 std::env::temp_dir().join(format!(
7108 "ferrum-server-{test_name}-{}.jsonl",
7109 Uuid::new_v4()
7110 ))
7111 }
7112
7113 fn only_replay_bundle(root: &Path) -> PathBuf {
7114 let mut dirs = fs::read_dir(root)
7115 .expect("read request dump dir")
7116 .filter_map(|entry| {
7117 let path = entry.expect("dir entry").path();
7118 path.is_dir().then_some(path)
7119 })
7120 .collect::<Vec<_>>();
7121 dirs.sort();
7122 assert_eq!(
7123 dirs.len(),
7124 1,
7125 "expected exactly one replay bundle in {root:?}"
7126 );
7127 dirs.remove(0)
7128 }
7129
7130 fn read_json_file(path: impl AsRef<Path>) -> Value {
7131 let path = path.as_ref();
7132 let text = fs::read_to_string(path).unwrap_or_else(|err| {
7133 panic!("failed to read {}: {}", path.display(), err);
7134 });
7135 serde_json::from_str(&text).unwrap_or_else(|err| {
7136 panic!("failed to parse {}: {}", path.display(), err);
7137 })
7138 }
7139
7140 fn read_profile_events(path: &Path) -> Vec<Value> {
7141 let text = fs::read_to_string(path)
7142 .unwrap_or_else(|err| panic!("failed to read {}: {}", path.display(), err));
7143 text.lines()
7144 .filter(|line| !line.trim().is_empty())
7145 .map(|line| serde_json::from_str::<Value>(line).expect("profile event json"))
7146 .collect()
7147 }
7148
7149 fn assert_chat_failure_replay_bundle(
7150 root: &Path,
7151 expected_phase: &str,
7152 expected_error_kind: &str,
7153 expected_message: &str,
7154 ) {
7155 let bundle = only_replay_bundle(root);
7156 let request = read_json_file(bundle.join("request.json"));
7157 let request_id = request["request_id"]
7158 .as_str()
7159 .expect("request id")
7160 .to_string();
7161 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
7162 assert_eq!(bad_scan["request_id"], request_id);
7163 assert_eq!(bad_scan["failure_kind"], "error");
7164 assert_eq!(bad_scan["failure_phase"], expected_phase);
7165 assert_eq!(bad_scan["error_kind"], expected_error_kind);
7166
7167 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
7168 assert_eq!(diagnostics["request_id"], request_id);
7169 assert_eq!(diagnostics["failure_kind"], "error");
7170 assert_eq!(diagnostics["first_failure_event"]["phase"], expected_phase);
7171 assert_eq!(
7172 diagnostics["first_failure_event"]["error_kind"],
7173 expected_error_kind
7174 );
7175 assert_eq!(diagnostics["nearest_request_id"], request_id);
7176 assert!(diagnostics["log_excerpt"]
7177 .as_str()
7178 .expect("log excerpt")
7179 .contains(expected_message));
7180 assert!(bundle.join("replay.command.json").is_file());
7181 }
7182
7183 fn assert_chat_success_replay_bundle(
7184 root: &Path,
7185 expected_token_ids: &[u32],
7186 expected_finish_reason: &str,
7187 expected_output_text: &str,
7188 ) {
7189 let bundle = only_replay_bundle(root);
7190 let request = read_json_file(bundle.join("request.json"));
7191 let request_id = request["request_id"]
7192 .as_str()
7193 .expect("request id")
7194 .to_string();
7195 let prompt_tokens = read_json_file(bundle.join("prompt_token_ids.json"));
7196 assert_eq!(prompt_tokens["request_id"], request_id);
7197 assert_eq!(prompt_tokens["token_ids"], json!([101, 202, 303]));
7198 assert_eq!(prompt_tokens["token_count"], 3);
7199 assert!(prompt_tokens["unavailable_reason"].is_null());
7200 let output_tokens = read_json_file(bundle.join("output_token_ids.json"));
7201 assert_eq!(output_tokens["request_id"], request_id);
7202 assert_eq!(output_tokens["token_ids"], json!(expected_token_ids));
7203 assert_eq!(output_tokens["token_count"], expected_token_ids.len());
7204 assert_eq!(output_tokens["finish_reason"], expected_finish_reason);
7205 assert!(output_tokens["unavailable_reason"].is_null());
7206
7207 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
7208 assert_eq!(bad_scan["request_id"], request_id);
7209 assert_eq!(bad_scan["bad_output"], false);
7210 assert_eq!(bad_scan["failure_kind"], serde_json::Value::Null);
7211 assert_eq!(
7212 bad_scan["output_chars"],
7213 expected_output_text.chars().count()
7214 );
7215 assert_eq!(
7216 bad_scan["classified_output_sha256"],
7217 sha256_hex(expected_output_text.as_bytes())
7218 );
7219
7220 let output_text_bytes = fs::read(bundle.join("output_text.txt")).unwrap();
7221 assert_eq!(bad_scan["output_sha256"], sha256_hex(&output_text_bytes));
7222 let output_text = String::from_utf8(output_text_bytes).unwrap();
7223 assert!(output_text.contains("[redacted actual output]"));
7224 assert!(output_text.contains(&format!(
7225 "sha256={}",
7226 sha256_hex(expected_output_text.as_bytes())
7227 )));
7228 assert!(output_text.contains(&format!("chars={}", expected_output_text.chars().count())));
7229
7230 let replay_body = read_json_file(bundle.join("replay_body.json"));
7231 assert_eq!(replay_body["messages"][0]["role"], "user");
7232 assert_eq!(replay_body["messages"][0]["content"], "[redacted]");
7233 assert_eq!(replay_body["messages"][0]["content_redacted"], true);
7234
7235 let replay = read_json_file(bundle.join("replay.command.json"));
7236 assert_eq!(replay["requires_running_server"], true);
7237 let argv = replay["argv"].as_array().expect("replay argv");
7238 assert!(argv.iter().any(|item| item == "--data-binary"));
7239 assert!(argv.iter().any(|item| {
7240 item.as_str()
7241 .is_some_and(|value| value.starts_with('@') && value.ends_with("replay_body.json"))
7242 }));
7243 assert_eq!(replay["engine_replay"]["requires_http_server"], false);
7244 let engine_argv = replay["engine_replay"]["argv"]
7245 .as_array()
7246 .expect("engine replay argv");
7247 assert!(engine_argv.iter().any(|item| item == "replay-bundle"));
7248 }
7249
7250 fn router_with_capturing_llm_and_template(
7251 template: ModelChatTemplate,
7252 ) -> (Router, Arc<CapturingLlm>) {
7253 router_with_capturing_llm_and_template_default(template, None)
7254 }
7255
7256 fn router_with_capturing_llm_and_template_default(
7257 template: ModelChatTemplate,
7258 default_enable_thinking: Option<bool>,
7259 ) -> (Router, Arc<CapturingLlm>) {
7260 let engine = Arc::new(CapturingLlm::new());
7261 let registry = ServedModelRegistry::try_new(
7262 "qwen3",
7263 ServedModelKind::Llm,
7264 vec!["served-alias".to_string()],
7265 vec![],
7266 )
7267 .unwrap();
7268 let router = AxumServer::from_llm(engine.clone())
7269 .with_served_model_registry(registry)
7270 .with_prompt_template(Some(template))
7271 .with_default_enable_thinking(default_enable_thinking)
7272 .build_router();
7273 (router, engine)
7274 }
7275
7276 fn qwen36_chat_template() -> ModelChatTemplate {
7277 ModelChatTemplate::new(
7278 include_str!("../tests/fixtures/chat_template/Qwen__Qwen3.6-35B-A3B/template.jinja"),
7279 "Qwen/Qwen3.6-35B-A3B",
7280 )
7281 }
7282
7283 async fn capture_qwen36_tool_history_request(
7284 reasoning_fields: Value,
7285 stream: bool,
7286 ) -> InferenceRequest {
7287 let mut assistant = json!({
7288 "role": "assistant",
7289 "content": null,
7290 "tool_calls": [{
7291 "id": "call_1",
7292 "type": "function",
7293 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
7294 }]
7295 });
7296 assistant
7297 .as_object_mut()
7298 .expect("assistant message object")
7299 .extend(
7300 reasoning_fields
7301 .as_object()
7302 .expect("reasoning fields object")
7303 .clone(),
7304 );
7305 let (router, engine) = router_with_capturing_llm_and_template(qwen36_chat_template());
7306 let response = post_json(
7307 router,
7308 "/v1/chat/completions",
7309 json!({
7310 "model": "served-alias",
7311 "messages": [
7312 {"role": "user", "content": "Use the weather tool."},
7313 assistant,
7314 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
7315 ],
7316 "tools": [{
7317 "type": "function",
7318 "function": {
7319 "name": "weather",
7320 "description": "Get weather",
7321 "parameters": {
7322 "type": "object",
7323 "properties": {"city": {"type": "string"}},
7324 "required": ["city"]
7325 }
7326 }
7327 }],
7328 "stream": stream
7329 }),
7330 )
7331 .await;
7332 assert_eq!(response.status(), AxumStatusCode::OK);
7333 if stream {
7334 assert!(response_text(response).await.contains("[DONE]"));
7335 }
7336 engine.last_request()
7337 }
7338
7339 fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
7340 let engine = Arc::new(CapturingLlm::new());
7341 let router = AxumServer::from_llm(engine.clone())
7342 .with_lora_adapters(
7343 "qwen3",
7344 vec![LoraAdapterModel::new(
7345 "sql",
7346 "qwen3:sql",
7347 "/tmp/sql-adapter",
7348 )],
7349 )
7350 .unwrap()
7351 .build_router();
7352 (router, engine)
7353 }
7354
7355 fn router_with_stub_embed() -> Router {
7356 AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
7357 }
7358
7359 fn router_with_stub_transcribe() -> Router {
7360 AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
7361 }
7362
7363 fn router_with_stub_tts() -> Router {
7364 AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
7365 }
7366
7367 async fn post_json(app: Router, path: &str, body: Value) -> Response {
7368 app.oneshot(
7369 Request::builder()
7370 .method("POST")
7371 .uri(path)
7372 .header(header::CONTENT_TYPE, "application/json")
7373 .body(Body::from(body.to_string()))
7374 .expect("request"),
7375 )
7376 .await
7377 .expect("route response")
7378 }
7379
7380 async fn post_json_with_benchmark_correlation(
7381 app: Router,
7382 path: &str,
7383 body: Value,
7384 correlation: &BenchmarkRequestCorrelation,
7385 ) -> Response {
7386 app.oneshot(
7387 Request::builder()
7388 .method("POST")
7389 .uri(path)
7390 .header(header::CONTENT_TYPE, "application/json")
7391 .header(BENCHMARK_RUN_ID_HEADER, &correlation.benchmark_run_id)
7392 .header(BENCHMARK_CELL_ID_HEADER, &correlation.cell_id)
7393 .header(
7394 BENCHMARK_REPEAT_INDEX_HEADER,
7395 correlation.repeat_index.to_string(),
7396 )
7397 .header(BENCHMARK_PHASE_HEADER, correlation.phase.as_str())
7398 .header(
7399 BENCHMARK_REQUEST_INDEX_HEADER,
7400 correlation.request_index.to_string(),
7401 )
7402 .body(Body::from(body.to_string()))
7403 .expect("request"),
7404 )
7405 .await
7406 .expect("route response")
7407 }
7408
7409 async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
7410 app.oneshot(
7411 Request::builder()
7412 .method("POST")
7413 .uri(path)
7414 .header(header::CONTENT_TYPE, "application/json")
7415 .body(Body::from(body.to_string()))
7416 .expect("request"),
7417 )
7418 .await
7419 .expect("route response")
7420 }
7421
7422 async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
7423 app.oneshot(
7424 Request::builder()
7425 .method("POST")
7426 .uri(path)
7427 .header(
7428 header::CONTENT_TYPE,
7429 format!("multipart/form-data; boundary={boundary}"),
7430 )
7431 .body(Body::from(body.to_string()))
7432 .expect("request"),
7433 )
7434 .await
7435 .expect("route response")
7436 }
7437
7438 async fn get(app: Router, path: &str) -> Response {
7439 app.oneshot(
7440 Request::builder()
7441 .method("GET")
7442 .uri(path)
7443 .body(Body::empty())
7444 .expect("request"),
7445 )
7446 .await
7447 .expect("route response")
7448 }
7449
7450 async fn response_json(response: Response) -> Value {
7451 let bytes = to_bytes(response.into_body(), usize::MAX)
7452 .await
7453 .expect("body bytes");
7454 serde_json::from_slice(&bytes).expect("json body")
7455 }
7456
7457 async fn response_text(response: Response) -> String {
7458 let bytes = to_bytes(response.into_body(), usize::MAX)
7459 .await
7460 .expect("body bytes");
7461 String::from_utf8(bytes.to_vec()).expect("utf8 body")
7462 }
7463
7464 fn responses_sse_json_events(body: &str) -> Vec<Value> {
7465 body.lines()
7466 .filter_map(|line| line.strip_prefix("data: "))
7467 .filter(|data| *data != "[DONE]")
7468 .map(|data| serde_json::from_str(data).expect("Responses SSE JSON event"))
7469 .collect()
7470 }
7471
7472 async fn response_bytes(response: Response) -> Vec<u8> {
7473 to_bytes(response.into_body(), usize::MAX)
7474 .await
7475 .expect("body bytes")
7476 .to_vec()
7477 }
7478
7479 async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
7480 let response = error.into_response();
7481 let status = response.status();
7482 (status, response_json(response).await)
7483 }
7484
7485 fn assert_openai_stream_error(body: &str, expected_message: &str) {
7486 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7487 assert!(
7488 body.contains("\"error\":{\"message\":\""),
7489 "stream failure should emit OpenAI error envelope: {body}"
7490 );
7491 assert!(
7492 body.contains(expected_message),
7493 "stream failure should include engine error message {expected_message:?}: {body}"
7494 );
7495 assert!(
7496 body.contains("\"type\":\"internal_server_error\""),
7497 "stream failure should use internal_server_error: {body}"
7498 );
7499 assert!(
7500 !body.contains("{\"error\":\""),
7501 "stream failure must not use legacy bare error payload: {body}"
7502 );
7503 }
7504
7505 fn chat_request(extra: Value) -> ChatCompletionsRequest {
7506 let mut value = json!({
7507 "model": "stub-model",
7508 "messages": [{"role": "user", "content": "hello"}],
7509 "max_tokens": 8
7510 });
7511 let obj = value.as_object_mut().unwrap();
7512 for (k, v) in extra.as_object().unwrap() {
7513 obj.insert(k.clone(), v.clone());
7514 }
7515 serde_json::from_value(value).expect("chat request")
7516 }
7517
7518 #[tokio::test]
7519 async fn responses_route_returns_sync_text_and_usage() {
7520 let response = post_json(
7521 router_with_stub("hello from ferrum"),
7522 "/v1/responses",
7523 json!({
7524 "model": "stub-model",
7525 "input": "hello",
7526 "store": false
7527 }),
7528 )
7529 .await;
7530 assert_eq!(response.status(), AxumStatusCode::OK);
7531 let body = response_json(response).await;
7532 assert_eq!(body["object"], "response");
7533 assert_eq!(body["status"], "completed");
7534 assert_eq!(body["store"], false);
7535 assert_eq!(body["output"][0]["type"], "message");
7536 assert_eq!(body["output"][0]["phase"], "final_answer");
7537 assert_eq!(body["output"][0]["content"][0]["text"], "hello from ferrum");
7538 assert_eq!(body["usage"]["input_tokens"], 7);
7539 assert_eq!(body["usage"]["output_tokens"], 2);
7540 assert_eq!(body["usage"]["total_tokens"], 9);
7541 assert_eq!(body["presence_penalty"], 0.0);
7542 assert_eq!(body["frequency_penalty"], 0.0);
7543 }
7544
7545 #[tokio::test]
7546 async fn responses_route_streams_ordered_text_events_once() {
7547 let response = post_json(
7548 router_with_stub_stream_chunks(&["he", "llo"]),
7549 "/v1/responses",
7550 json!({
7551 "model": "stub-model",
7552 "input": [{"role": "user", "content": "say hello"}],
7553 "stream": true
7554 }),
7555 )
7556 .await;
7557 assert_eq!(response.status(), AxumStatusCode::OK);
7558 let body = response_text(response).await;
7559 for event in [
7560 "response.created",
7561 "response.output_item.added",
7562 "response.output_text.delta",
7563 "response.output_text.done",
7564 "response.output_item.done",
7565 "response.completed",
7566 ] {
7567 assert!(
7568 body.contains(&format!("event: {event}")),
7569 "missing {event}: {body}"
7570 );
7571 }
7572 assert_eq!(
7573 body.matches("event: response.completed").count(),
7574 1,
7575 "completed must be emitted exactly once: {body}"
7576 );
7577 assert!(
7578 body.contains("\"delta\":\"he\""),
7579 "missing first delta: {body}"
7580 );
7581 assert!(
7582 body.contains("\"delta\":\"llo\""),
7583 "missing second delta: {body}"
7584 );
7585 assert!(body.contains("\"input_tokens\":5"), "missing usage: {body}");
7586 let events = responses_sse_json_events(&body);
7587 let message_added = events
7588 .iter()
7589 .find(|event| {
7590 event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7591 })
7592 .expect("message item added");
7593 assert!(
7594 message_added["item"].get("phase").is_none(),
7595 "stream must not guess phase before later tool calls are known: {body}"
7596 );
7597 let message_done = events
7598 .iter()
7599 .find(|event| {
7600 event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7601 })
7602 .expect("message item done");
7603 assert_eq!(message_done["item"]["phase"], "final_answer");
7604 let terminal = events
7605 .iter()
7606 .find(|event| event["type"] == "response.completed")
7607 .expect("completed response");
7608 assert_eq!(terminal["response"]["output"][0]["phase"], "final_answer");
7609 let completed = body
7610 .find("event: response.completed")
7611 .expect("completed event");
7612 let done = body.find("data: [DONE]").expect("terminal DONE marker");
7613 assert!(
7614 completed < done,
7615 "DONE must follow response.completed: {body}"
7616 );
7617 }
7618
7619 #[tokio::test]
7620 async fn responses_route_supports_stateless_function_round_trip() {
7621 let tool = json!({
7622 "type": "function",
7623 "name": "weather",
7624 "description": "Get weather",
7625 "parameters": {
7626 "type": "object",
7627 "properties": {"city": {"type": "string"}},
7628 "required": ["city"]
7629 }
7630 });
7631 let first = post_json(
7632 router_with_stub_api_response("", weather_tool_api_response()),
7633 "/v1/responses",
7634 json!({
7635 "model": "stub-model",
7636 "input": "Use the weather tool",
7637 "tools": [tool.clone()],
7638 "tool_choice": "auto"
7639 }),
7640 )
7641 .await;
7642 assert_eq!(first.status(), AxumStatusCode::OK);
7643 let first_body = response_json(first).await;
7644 let call = first_body["output"][0].clone();
7645 assert_eq!(call["type"], "function_call");
7646 assert_eq!(call["call_id"], "call_1");
7647 assert_eq!(call["name"], "weather");
7648 assert_eq!(call["arguments"], "{\"city\":\"Paris\"}");
7649
7650 let second = post_json(
7651 router_with_stub("weather received"),
7652 "/v1/responses",
7653 json!({
7654 "model": "stub-model",
7655 "input": [
7656 {"role": "user", "content": "Use the weather tool"},
7657 call,
7658 {"type": "function_call_output", "call_id": "call_1", "output": "sunny"}
7659 ],
7660 "tools": [tool]
7661 }),
7662 )
7663 .await;
7664 assert_eq!(second.status(), AxumStatusCode::OK);
7665 let second_body = response_json(second).await;
7666 assert_eq!(
7667 second_body["output"][0]["content"][0]["text"],
7668 "weather received"
7669 );
7670 }
7671
7672 #[tokio::test]
7673 async fn responses_route_marks_text_before_calls_as_commentary() {
7674 let request = || {
7675 json!({
7676 "model": "stub-model",
7677 "input": "Use the weather tool",
7678 "stream": false,
7679 "tools": [{
7680 "type": "function",
7681 "name": "weather",
7682 "parameters": {"type": "object"}
7683 }]
7684 })
7685 };
7686 let sync = post_json(
7687 router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7688 "/v1/responses",
7689 request(),
7690 )
7691 .await;
7692 assert_eq!(sync.status(), AxumStatusCode::OK);
7693 let sync = response_json(sync).await;
7694 assert_eq!(sync["output"][0]["type"], "message");
7695 assert_eq!(sync["output"][0]["phase"], "commentary");
7696 assert_eq!(sync["output"][1]["type"], "function_call");
7697
7698 let mut stream_request = request();
7699 stream_request["stream"] = json!(true);
7700 let stream = post_json(
7701 router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7702 "/v1/responses",
7703 stream_request,
7704 )
7705 .await;
7706 assert_eq!(stream.status(), AxumStatusCode::OK);
7707 let body = response_text(stream).await;
7708 let events = responses_sse_json_events(&body);
7709 let message_added = events
7710 .iter()
7711 .find(|event| {
7712 event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7713 })
7714 .expect("message item added");
7715 assert!(message_added["item"].get("phase").is_none());
7716 let message_done = events
7717 .iter()
7718 .find(|event| {
7719 event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7720 })
7721 .expect("message item done");
7722 assert_eq!(message_done["item"]["phase"], "commentary");
7723 let terminal = events
7724 .iter()
7725 .find(|event| event["type"] == "response.completed")
7726 .expect("completed response");
7727 assert_eq!(terminal["response"]["output"][0]["phase"], "commentary");
7728 assert_eq!(terminal["response"]["output"][1]["type"], "function_call");
7729 }
7730
7731 #[tokio::test]
7732 async fn responses_route_accepts_real_caller_owned_second_turn_shape() {
7733 let response = post_json(
7734 router_with_stub("You first said hello."),
7735 "/v1/responses",
7736 json!({
7737 "model": "stub-model",
7738 "instructions": "Answer from the supplied history.",
7739 "input": [
7740 {
7741 "type": "message",
7742 "role": "user",
7743 "content": [{"type": "input_text", "text": "Hello"}]
7744 },
7745 {
7746 "type": "message",
7747 "role": "assistant",
7748 "content": [{"type": "output_text", "text": "Hi there!"}]
7749 },
7750 {
7751 "type": "reasoning",
7752 "encrypted_content": null,
7753 "summary": []
7754 },
7755 {
7756 "type": "message",
7757 "role": "user",
7758 "content": [{"type": "input_text", "text": "What did I say first?"}]
7759 }
7760 ],
7761 "store": false,
7762 "stream": false,
7763 "include": ["reasoning.encrypted_content"],
7764 "parallel_tool_calls": false,
7765 "prompt_cache_key": "thread-1",
7766 "reasoning": {"effort": "high", "summary": "auto"}
7767 }),
7768 )
7769 .await;
7770 assert_eq!(response.status(), AxumStatusCode::OK);
7771 let body = response_json(response).await;
7772 assert_eq!(
7773 body["output"][0]["content"][0]["text"],
7774 "You first said hello."
7775 );
7776 assert_eq!(body["parallel_tool_calls"], false);
7777 assert_eq!(body["prompt_cache_key"], "thread-1");
7778 assert_eq!(body["reasoning"]["effort"], "high");
7779 }
7780
7781 #[tokio::test]
7782 async fn responses_route_merges_instructions_with_leading_developer_message() {
7783 let template = ModelChatTemplate::new(
7784 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7785 "strict-leading-system-template",
7786 );
7787 let response = post_json(
7788 router_with_stub_and_template("ok", template),
7789 "/v1/responses",
7790 json!({
7791 "model": "stub-model",
7792 "instructions": "Top-level instructions",
7793 "input": [
7794 {"type": "message", "role": "developer", "content": "Developer instructions"},
7795 {"type": "message", "role": "user", "content": "Hello"}
7796 ]
7797 }),
7798 )
7799 .await;
7800 assert_eq!(response.status(), AxumStatusCode::OK);
7801 }
7802
7803 #[tokio::test]
7804 async fn responses_route_adapts_interleaved_system_for_strict_template() {
7805 let template = ModelChatTemplate::new(
7806 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}{% endfor %}{% if messages|length != 2 %}{{ raise_exception('expected two messages') }}{% endif %}{% if messages[0].role != 'system' %}{{ raise_exception('system must be first') }}{% endif %}{% if messages[0].content != 'Initial instructions\\n\\nDeferred tool instructions' %}{{ raise_exception('system instructions were not preserved') }}{% endif %}[{{ messages[0].role }}]{{ messages[0].content }}[{{ messages[1].role }}]{{ messages[1].content }}",
7807 "strict-leading-system-template",
7808 );
7809 let response = post_json(
7810 router_with_stub_and_template("ok", template),
7811 "/v1/responses",
7812 json!({
7813 "model": "stub-model",
7814 "input": [
7815 {"role": "system", "content": "Initial instructions"},
7816 {"role": "user", "content": "Use the available tool"},
7817 {"role": "developer", "content": "Deferred tool instructions"}
7818 ]
7819 }),
7820 )
7821 .await;
7822 assert_eq!(response.status(), AxumStatusCode::OK);
7823 }
7824
7825 #[tokio::test]
7826 async fn responses_route_keeps_phase_aligned_through_system_injection() {
7827 let template = ModelChatTemplate::new(
7828 "{% if messages|length != 3 %}{{ raise_exception('expected three messages') }}{% endif %}{% if messages[0].role != 'system' %}{{ raise_exception('system must be first') }}{% endif %}{% if messages[1].role != 'assistant' or messages[1].phase != 'commentary' %}{{ raise_exception('assistant phase was not preserved') }}{% endif %}{% if messages[2].role != 'user' or messages[2].phase is defined %}{{ raise_exception('phase metadata shifted') }}{% endif %}[assistant]",
7829 "phase-alignment-template",
7830 );
7831 let response = post_json(
7832 router_with_stub_and_template(r#"{"ok":true}"#, template),
7833 "/v1/responses",
7834 json!({
7835 "model": "stub-model",
7836 "instructions": "Top-level instructions",
7837 "input": [
7838 {
7839 "type": "message",
7840 "role": "assistant",
7841 "phase": "commentary",
7842 "content": "I will inspect."
7843 },
7844 {"type": "message", "role": "user", "content": "Continue"}
7845 ],
7846 "text": {"format": {"type": "json_object"}}
7847 }),
7848 )
7849 .await;
7850 assert_eq!(response.status(), AxumStatusCode::OK);
7851 }
7852
7853 #[tokio::test]
7854 async fn responses_route_can_disable_interleaved_system_coalescing() {
7855 let template = ModelChatTemplate::new(
7856 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7857 "strict-leading-system-template",
7858 );
7859 let mut engine = CapturingLlm::new();
7860 engine.config.model.model_id = ModelId::new("stub-model");
7861 let engine = Arc::new(engine);
7862 let router = AxumServer::from_state(
7863 AppState::default()
7864 .with_llm(engine.clone())
7865 .with_prompt_template(Some(template))
7866 .with_interleaved_system_coalescing(false),
7867 )
7868 .build_router();
7869 let response = post_json(
7870 router,
7871 "/v1/responses",
7872 json!({
7873 "model": "stub-model",
7874 "input": [
7875 {"role": "system", "content": "Initial instructions"},
7876 {"role": "user", "content": "Use the available tool"},
7877 {"role": "developer", "content": "Deferred tool instructions"}
7878 ]
7879 }),
7880 )
7881 .await;
7882 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7883 let body = response_json(response).await;
7884 assert_eq!(body["error"]["type"], "invalid_request_error");
7885 assert!(!engine.has_captured_request());
7886 assert!(
7887 body.to_string()
7888 .contains("System message must be at the beginning."),
7889 "{body}"
7890 );
7891 }
7892
7893 #[tokio::test]
7894 async fn chat_route_applies_and_can_disable_interleaved_system_coalescing() {
7895 let template = || {
7896 ModelChatTemplate::new(
7897 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7898 "strict-leading-system-template",
7899 )
7900 };
7901 let request = || {
7902 json!({
7903 "model": "stub-model",
7904 "messages": [
7905 {"role": "system", "content": "Initial instructions"},
7906 {"role": "user", "content": "Use the available tool"},
7907 {"role": "system", "content": "Deferred tool instructions"}
7908 ]
7909 })
7910 };
7911
7912 let enabled = post_json(
7913 router_with_stub_and_template("ok", template()),
7914 "/v1/chat/completions",
7915 request(),
7916 )
7917 .await;
7918 assert_eq!(enabled.status(), AxumStatusCode::OK);
7919
7920 let consecutive = post_json(
7921 router_with_stub_and_template("ok", template()),
7922 "/v1/chat/completions",
7923 json!({
7924 "model": "stub-model",
7925 "messages": [
7926 {"role": "system", "content": "Initial instructions"},
7927 {"role": "system", "content": "Deferred tool instructions"},
7928 {"role": "user", "content": "Use the available tool"}
7929 ]
7930 }),
7931 )
7932 .await;
7933 assert_eq!(consecutive.status(), AxumStatusCode::OK);
7934
7935 let mut engine = CapturingLlm::new();
7936 engine.config.model.model_id = ModelId::new("stub-model");
7937 let engine = Arc::new(engine);
7938 let disabled_router = AxumServer::from_state(
7939 AppState::default()
7940 .with_llm(engine.clone())
7941 .with_prompt_template(Some(template()))
7942 .with_interleaved_system_coalescing(false),
7943 )
7944 .build_router();
7945 let disabled = post_json(disabled_router, "/v1/chat/completions", request()).await;
7946 assert_eq!(disabled.status(), AxumStatusCode::BAD_REQUEST);
7947 let body = response_json(disabled).await;
7948 assert_eq!(body["error"]["type"], "invalid_request_error");
7949 assert!(!engine.has_captured_request());
7950 assert!(
7951 body.to_string()
7952 .contains("System message must be at the beginning."),
7953 "{body}"
7954 );
7955 }
7956
7957 #[tokio::test]
7958 async fn responses_route_keeps_structured_output_to_one_leading_system_message() {
7959 let template = ModelChatTemplate::new(
7960 "{% for message in messages %}{% if message.role == 'system' and not loop.first %}{{ raise_exception('System message must be at the beginning.') }}{% endif %}[{{ message.role }}]{{ message.content }}{% endfor %}",
7961 "strict-leading-system-template",
7962 );
7963 let response = post_json(
7964 router_with_stub_and_template(r#"{"ok":true}"#, template),
7965 "/v1/responses",
7966 json!({
7967 "model": "stub-model",
7968 "instructions": "Top-level instructions",
7969 "input": [
7970 {"type": "message", "role": "developer", "content": "Developer instructions"},
7971 {"type": "message", "role": "user", "content": "Return JSON"}
7972 ],
7973 "text": {"format": {"type": "json_object"}}
7974 }),
7975 )
7976 .await;
7977 assert_eq!(response.status(), AxumStatusCode::OK);
7978 let body = response_json(response).await;
7979 assert_eq!(body["output"][0]["content"][0]["text"], r#"{"ok":true}"#);
7980 }
7981
7982 #[tokio::test]
7983 async fn responses_route_output_can_be_replayed_with_readable_reasoning() {
7984 let first = post_json(
7985 router_with_stub("<think>Checked the supplied facts.</think>\nFirst answer"),
7986 "/v1/responses",
7987 json!({
7988 "model": "stub-model",
7989 "input": "First question",
7990 "include": ["reasoning.encrypted_content"]
7991 }),
7992 )
7993 .await;
7994 assert_eq!(first.status(), AxumStatusCode::OK);
7995 let first_body = response_json(first).await;
7996 assert_eq!(first_body["output"][0]["type"], "reasoning");
7997 assert_eq!(
7998 first_body["output"][0]["content"][0]["text"],
7999 "Checked the supplied facts."
8000 );
8001 assert_eq!(first_body["output"][0]["encrypted_content"], Value::Null);
8002 assert_eq!(first_body["output"][1]["type"], "message");
8003
8004 let mut input = vec![json!({
8005 "type": "message",
8006 "role": "user",
8007 "content": [{"type": "input_text", "text": "First question"}]
8008 })];
8009 input.extend(first_body["output"].as_array().unwrap().iter().cloned());
8010 input.push(json!({
8011 "type": "message",
8012 "role": "user",
8013 "content": [{"type": "input_text", "text": "Continue"}]
8014 }));
8015 let second = post_json(
8016 router_with_stub("Second answer"),
8017 "/v1/responses",
8018 json!({"model": "stub-model", "input": input}),
8019 )
8020 .await;
8021 assert_eq!(second.status(), AxumStatusCode::OK);
8022 let second_body = response_json(second).await;
8023 assert_eq!(
8024 second_body["output"][0]["content"][0]["text"],
8025 "Second answer"
8026 );
8027 }
8028
8029 #[tokio::test]
8030 async fn responses_route_streams_reasoning_before_text_with_stable_indices() {
8031 let response = post_json(
8032 router_with_stub_stream_chunks(&["<think>inspect", " history</think>\nfinal"]),
8033 "/v1/responses",
8034 json!({
8035 "model": "stub-model",
8036 "input": "answer",
8037 "stream": true,
8038 "include": ["reasoning.encrypted_content"]
8039 }),
8040 )
8041 .await;
8042 assert_eq!(response.status(), AxumStatusCode::OK);
8043 let body = response_text(response).await;
8044 let events = responses_sse_json_events(&body);
8045 for (sequence, event) in events.iter().enumerate() {
8046 assert_eq!(
8047 event["sequence_number"], sequence,
8048 "Responses sequence numbers must be contiguous: {body}"
8049 );
8050 }
8051 for event in [
8052 "response.reasoning_text.delta",
8053 "response.reasoning_text.done",
8054 "response.output_text.delta",
8055 "response.completed",
8056 ] {
8057 assert!(
8058 body.contains(&format!("event: {event}")),
8059 "missing {event}: {body}"
8060 );
8061 }
8062 let reasoning_done = body
8063 .find("event: response.reasoning_text.done")
8064 .expect("reasoning done");
8065 let text_added = body[reasoning_done..]
8066 .find("event: response.output_item.added")
8067 .map(|offset| reasoning_done + offset)
8068 .expect("text item added");
8069 assert!(
8070 reasoning_done < text_added,
8071 "reasoning must finish before text: {body}"
8072 );
8073 assert!(
8074 body.contains("\"output_index\":0,\"content_index\":0,\"delta\":\"inspect"),
8075 "reasoning must use output index 0: {body}"
8076 );
8077 assert!(
8078 body.contains("\"output_index\":1,\"content_index\":0,\"delta\":\"final"),
8079 "text must use output index 1: {body}"
8080 );
8081 let reasoning_added = events
8082 .iter()
8083 .find(|event| {
8084 event["type"] == "response.output_item.added"
8085 && event["item"]["type"] == "reasoning"
8086 })
8087 .expect("reasoning item added");
8088 assert_eq!(reasoning_added["item"]["status"], "in_progress");
8089 let reasoning_part_added = events
8090 .iter()
8091 .find(|event| {
8092 event["type"] == "response.content_part.added"
8093 && event["part"]["type"] == "reasoning_text"
8094 })
8095 .expect("reasoning content part added");
8096 assert_eq!(reasoning_part_added["output_index"], 0);
8097 let reasoning_item_done = events
8098 .iter()
8099 .find(|event| {
8100 event["type"] == "response.output_item.done" && event["item"]["type"] == "reasoning"
8101 })
8102 .expect("reasoning item done");
8103 assert_eq!(reasoning_item_done["item"]["status"], "completed");
8104 let terminal = events
8105 .iter()
8106 .find(|event| event["type"] == "response.completed")
8107 .expect("terminal response");
8108 assert_eq!(
8109 terminal["response"]["output"][0],
8110 reasoning_item_done["item"]
8111 );
8112 assert!(
8113 body.contains("data: [DONE]"),
8114 "missing terminal marker: {body}"
8115 );
8116 }
8117
8118 #[tokio::test]
8119 async fn responses_route_streams_function_call_events() {
8120 let response = post_json(
8121 router_with_stub_api_response("", weather_tool_api_response()),
8122 "/v1/responses",
8123 json!({
8124 "model": "stub-model",
8125 "input": "Use the weather tool",
8126 "stream": true,
8127 "tools": [{
8128 "type": "function",
8129 "name": "weather",
8130 "parameters": {"type": "object"}
8131 }]
8132 }),
8133 )
8134 .await;
8135 assert_eq!(response.status(), AxumStatusCode::OK);
8136 let body = response_text(response).await;
8137 assert!(
8138 body.contains("event: response.function_call_arguments.delta"),
8139 "missing function delta: {body}"
8140 );
8141 assert!(
8142 body.contains("event: response.function_call_arguments.done"),
8143 "missing function done: {body}"
8144 );
8145 assert!(
8146 body.contains("\"call_id\":\"call_1\""),
8147 "missing call id: {body}"
8148 );
8149 assert_eq!(body.matches("event: response.completed").count(), 1);
8150 }
8151
8152 #[tokio::test]
8153 async fn responses_route_round_trips_namespace_identity_without_leaking_chat_alias() {
8154 let namespace_tool = json!({
8155 "type": "namespace",
8156 "name": "collaboration",
8157 "description": "Agent coordination tools",
8158 "tools": [{
8159 "type": "function",
8160 "name": "wait_agent",
8161 "parameters": {"type": "object"}
8162 }]
8163 });
8164 let sync = post_json(
8165 router_with_stub_api_response("", namespaced_tool_api_response()),
8166 "/v1/responses",
8167 json!({
8168 "model": "stub-model",
8169 "input": "Wait for the agent",
8170 "tools": [namespace_tool.clone()]
8171 }),
8172 )
8173 .await;
8174 assert_eq!(sync.status(), AxumStatusCode::OK);
8175 let sync_body = response_json(sync).await;
8176 assert_eq!(sync_body["output"][0]["type"], "function_call");
8177 assert_eq!(sync_body["output"][0]["namespace"], "collaboration");
8178 assert_eq!(sync_body["output"][0]["name"], "wait_agent");
8179
8180 let stream = post_json(
8181 router_with_stub_api_response("", namespaced_tool_api_response()),
8182 "/v1/responses",
8183 json!({
8184 "model": "stub-model",
8185 "input": "Wait for the agent",
8186 "tools": [namespace_tool],
8187 "stream": true
8188 }),
8189 )
8190 .await;
8191 assert_eq!(stream.status(), AxumStatusCode::OK);
8192 let stream_body = response_text(stream).await;
8193 let events = responses_sse_json_events(&stream_body);
8194 let function_events = events
8195 .iter()
8196 .filter(|event| {
8197 event["item"]["type"] == "function_call"
8198 || event["type"] == "response.function_call_arguments.done"
8199 })
8200 .collect::<Vec<_>>();
8201 assert!(!function_events.is_empty());
8202 for event in function_events {
8203 let value = event.get("item").unwrap_or(event);
8204 assert_eq!(value["namespace"], "collaboration");
8205 assert_eq!(value["name"], "wait_agent");
8206 }
8207 assert!(stream_body.contains("data: [DONE]"));
8208 assert!(!stream_body.contains("collaboration__wait_agent"));
8209 }
8210
8211 #[tokio::test]
8212 async fn responses_route_enforces_parallel_tool_call_constraint() {
8213 let tools = json!([
8214 {"type": "function", "name": "weather", "parameters": {"type": "object"}},
8215 {"type": "function", "name": "clock", "parameters": {"type": "object"}}
8216 ]);
8217 let sync = post_json(
8218 router_with_stub_api_response("", two_tool_api_response()),
8219 "/v1/responses",
8220 json!({
8221 "model": "stub-model",
8222 "input": "Use both tools",
8223 "tools": tools.clone(),
8224 "parallel_tool_calls": false
8225 }),
8226 )
8227 .await;
8228 assert_eq!(sync.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
8229
8230 let stream = post_json(
8231 router_with_stub_api_response("", two_tool_api_response()),
8232 "/v1/responses",
8233 json!({
8234 "model": "stub-model",
8235 "input": "Use both tools",
8236 "tools": tools,
8237 "parallel_tool_calls": false,
8238 "stream": true
8239 }),
8240 )
8241 .await;
8242 assert_eq!(stream.status(), AxumStatusCode::OK);
8243 let body = response_text(stream).await;
8244 assert!(
8245 body.contains("event: response.failed"),
8246 "missing failure: {body}"
8247 );
8248 assert!(
8249 !body.contains("event: response.completed"),
8250 "must not complete: {body}"
8251 );
8252 assert!(
8253 body.contains("data: [DONE]"),
8254 "missing terminal marker: {body}"
8255 );
8256 }
8257
8258 #[tokio::test]
8259 async fn responses_route_streams_incomplete_terminal_event() {
8260 let response = post_json(
8261 router_with_stub_finish_reason("partial", FinishReason::Length),
8262 "/v1/responses",
8263 json!({"model": "stub-model", "input": "answer", "stream": true}),
8264 )
8265 .await;
8266 assert_eq!(response.status(), AxumStatusCode::OK);
8267 let body = response_text(response).await;
8268 assert!(
8269 body.contains("event: response.incomplete"),
8270 "missing incomplete terminal event: {body}"
8271 );
8272 assert!(
8273 !body.contains("event: response.completed"),
8274 "incomplete response must not emit completed: {body}"
8275 );
8276 assert!(
8277 body.contains("data: [DONE]"),
8278 "missing terminal marker: {body}"
8279 );
8280 let events = responses_sse_json_events(&body);
8281 let output_done = events
8282 .iter()
8283 .find(|event| event["type"] == "response.output_item.done")
8284 .expect("incomplete output item done event");
8285 assert_eq!(output_done["item"]["status"], "incomplete");
8286 let terminal = events
8287 .iter()
8288 .find(|event| event["type"] == "response.incomplete")
8289 .expect("incomplete terminal event");
8290 assert_eq!(terminal["response"]["output"][0]["status"], "incomplete");
8291 }
8292
8293 #[tokio::test]
8294 async fn responses_route_marks_sync_length_output_incomplete() {
8295 let response = post_json(
8296 router_with_stub_finish_reason("partial", FinishReason::Length),
8297 "/v1/responses",
8298 json!({"model": "stub-model", "input": "answer"}),
8299 )
8300 .await;
8301 assert_eq!(response.status(), AxumStatusCode::OK);
8302 let body = response_json(response).await;
8303 assert_eq!(body["status"], "incomplete");
8304 assert_eq!(body["output"][0]["status"], "incomplete");
8305 }
8306
8307 #[tokio::test]
8308 async fn responses_route_rejects_state_and_non_function_tools() {
8309 for (extra, param) in [
8310 (json!({"store": true}), "store"),
8311 (
8312 json!({"previous_response_id": "resp_previous"}),
8313 "previous_response_id",
8314 ),
8315 (
8316 json!({"tools": [{"type": "mcp", "server_label": "docs"}]}),
8317 "tools[0].type",
8318 ),
8319 ] {
8320 let mut body = json!({"model": "stub-model", "input": "hello"});
8321 body.as_object_mut()
8322 .unwrap()
8323 .extend(extra.as_object().unwrap().clone());
8324 let response = post_json(router_with_stub("unused"), "/v1/responses", body).await;
8325 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8326 let error = response_json(response).await;
8327 assert_eq!(error["error"]["param"], param, "error: {error}");
8328 }
8329 }
8330
8331 #[tokio::test]
8332 async fn responses_mvp_keeps_chat_completions_route_working() {
8333 let response = post_json(
8334 router_with_stub("chat still works"),
8335 "/v1/chat/completions",
8336 json!({
8337 "model": "stub-model",
8338 "messages": [{"role": "user", "content": "hello"}]
8339 }),
8340 )
8341 .await;
8342 assert_eq!(response.status(), AxumStatusCode::OK);
8343 let body = response_json(response).await;
8344 assert_eq!(body["choices"][0]["message"]["content"], "chat still works");
8345 }
8346
8347 #[test]
8348 fn sanitized_chat_request_body_redacts_user_text_and_secret_metadata() {
8349 let request = chat_request(json!({
8350 "messages": [{"role": "user", "content": "private prompt"}],
8351 "metadata": {"api_key": "should-not-survive"},
8352 "stream": true
8353 }));
8354 let body = sanitized_chat_request_body(&request);
8355 assert_eq!(body["model"], "stub-model");
8356 assert_eq!(body["stream"], true);
8357 assert_eq!(body["messages"][0]["role"], "user");
8358 assert_eq!(body["messages"][0]["content"], "[redacted]");
8359 assert_eq!(body["messages"][0]["content_redacted"], true);
8360 assert_eq!(body["messages"][0]["content_chars"], 14);
8361 assert_eq!(body["metadata"]["api_key"], "[redacted]");
8362 }
8363
8364 #[test]
8365 fn admission_health_prefers_runtime_authority_over_preflight_estimate() {
8366 let engine_status = EngineStatus {
8367 is_ready: true,
8368 loaded_models: Vec::new(),
8369 active_requests: 2,
8370 queued_requests: 1,
8371 memory_usage: MemoryUsage {
8372 total_bytes: 0,
8373 used_bytes: 0,
8374 free_bytes: 0,
8375 gpu_memory_bytes: None,
8376 cpu_memory_bytes: None,
8377 cache_memory_bytes: 0,
8378 utilization_percent: 0.0,
8379 },
8380 uptime_seconds: 0,
8381 last_heartbeat: chrono::Utc::now(),
8382 version: "test".to_owned(),
8383 };
8384 let runtime = ferrum_types::ExecutorAdmissionSnapshot::new(
8385 ferrum_types::ExecutionResourceAuthority::PlanRuntime,
8386 ferrum_types::ExecutorAdmissionLimits::new(32, 4096).unwrap(),
8387 2,
8388 7,
8389 23,
8390 None,
8391 Some(3),
8392 )
8393 .unwrap();
8394 let admission = admission_health_json(
8395 &engine_status,
8396 &EngineMetrics::default(),
8397 &json!({
8398 "admission": {
8399 "effective_max_concurrent": 16,
8400 "scheduler_policy": "continuous"
8401 }
8402 }),
8403 Some(&runtime),
8404 None,
8405 );
8406
8407 assert_eq!(admission["source"], "runtime_executor");
8408 assert_eq!(admission["runtime_snapshot_available"], true);
8409 assert_eq!(admission["resource_authority"], "plan_runtime");
8410 assert_eq!(admission["effective_max_concurrent"], 32);
8411 assert_eq!(admission["maximum_active_sequences"], 32);
8412 assert_eq!(admission["maximum_scheduled_tokens"], 4096);
8413 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8414 assert_eq!(admission["active_sequences"], 30);
8415 assert_eq!(admission["active_prefill"], 7);
8416 assert_eq!(admission["active_decode"], 23);
8417 assert!(admission["current_batch_size"].is_null());
8418 assert_eq!(admission["queue_depth"], 2);
8419 assert_eq!(admission["capacity_blocked_requests"], 3);
8420 }
8421
8422 #[test]
8423 fn admission_health_surfaces_runtime_contract_failure_without_preflight_fallback() {
8424 let engine_status = EngineStatus {
8425 is_ready: true,
8426 loaded_models: Vec::new(),
8427 active_requests: 32,
8428 queued_requests: 1,
8429 memory_usage: MemoryUsage {
8430 total_bytes: 0,
8431 used_bytes: 0,
8432 free_bytes: 0,
8433 gpu_memory_bytes: None,
8434 cpu_memory_bytes: None,
8435 cache_memory_bytes: 0,
8436 utilization_percent: 0.0,
8437 },
8438 uptime_seconds: 0,
8439 last_heartbeat: chrono::Utc::now(),
8440 version: "test".to_owned(),
8441 };
8442 let admission = admission_health_json(
8443 &engine_status,
8444 &EngineMetrics::default(),
8445 &json!({
8446 "admission": {
8447 "effective_max_concurrent": 16,
8448 "scheduler_policy": "continuous"
8449 }
8450 }),
8451 None,
8452 Some("active phase count exceeded the runtime ceiling"),
8453 );
8454
8455 assert_eq!(admission["source"], "runtime_error");
8456 assert_eq!(admission["runtime_snapshot_available"], false);
8457 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8458 assert!(admission["effective_max_concurrent"].is_null());
8459 assert!(admission["queue_depth"].is_null());
8460 assert_eq!(
8461 admission["runtime_contract_error"],
8462 "active phase count exceeded the runtime ceiling"
8463 );
8464 }
8465
8466 #[tokio::test]
8467 async fn route_health_includes_runtime_config_snapshot() {
8468 let response = get(router_with_stub("ok"), "/health").await;
8469 assert_eq!(response.status(), AxumStatusCode::OK);
8470 let body = response_json(response).await;
8471 assert_eq!(body["status"], "healthy");
8472 assert!(body["config"]["entries"].is_array(), "body: {body}");
8473 assert_eq!(body["auto_config"]["schema_version"], 1);
8474 assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
8475 assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
8476 assert_eq!(body["admission"]["schema_version"], 2);
8477 assert!(body["admission"]["effective_max_concurrent"].is_number());
8478 assert!(body["admission"]["queue_depth"].is_number());
8479 assert!(body["admission"]["active_sequences"].is_number());
8480 assert!(body["admission"]["active_prefill"].is_null());
8481 assert!(body["admission"]["active_decode"].is_null());
8482 assert!(body["admission"]["current_batch_size"].is_null());
8483 assert!(body["admission"]["rejected_requests_total"].is_number());
8484 assert!(body["admission"]["failed_requests_total"].is_number());
8485 assert!(body["admission"]["completed_requests_total"].is_number());
8486 assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
8487 assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
8488 assert!(body["scheduler"]["scheduling_time_ms"].is_number());
8489 assert!(body["scheduler"]["model_execution_time_ms"].is_number());
8490 assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
8491 assert!(
8492 body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
8493 "body: {body}"
8494 );
8495 }
8496
8497 #[tokio::test]
8498 async fn route_metrics_includes_admission_counters() {
8499 let response = get(router_with_stub("ok"), "/metrics").await;
8500 assert_eq!(response.status(), AxumStatusCode::OK);
8501 let body = response_text(response).await;
8502 for metric in [
8503 "ferrum_admission_runtime_snapshot_available",
8504 "ferrum_admission_effective_max_concurrent",
8505 "ferrum_admission_queue_depth",
8506 "ferrum_admission_active_sequences",
8507 "ferrum_admission_rejected_requests_total",
8508 "ferrum_admission_failed_requests_total",
8509 "ferrum_admission_completed_requests_total",
8510 ] {
8511 assert!(body.contains(metric), "missing {metric}:\n{body}");
8512 }
8513 for unavailable_metric in [
8514 "ferrum_admission_maximum_active_sequences ",
8515 "ferrum_admission_maximum_scheduled_tokens ",
8516 "ferrum_admission_capacity_blocked_requests ",
8517 "ferrum_admission_active_prefill ",
8518 "ferrum_admission_active_decode ",
8519 "ferrum_admission_current_batch_size ",
8520 ] {
8521 assert!(
8522 !body.contains(unavailable_metric),
8523 "unknown metric was encoded as a real value: {unavailable_metric}\n{body}"
8524 );
8525 }
8526 }
8527
8528 #[tokio::test]
8529 async fn route_health_includes_engine_lora_metrics_snapshot() {
8530 let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
8531 "ok",
8532 json!({
8533 "enabled": true,
8534 "adapter_count": 1,
8535 "active_cache_bindings": 0,
8536 "projection_applications": 7,
8537 "position": "real-inference",
8538 "source": "test-lora",
8539 }),
8540 )))
8541 .with_lora_adapters(
8542 "stub-model",
8543 vec![LoraAdapterModel::new(
8544 "sql",
8545 "stub-model:sql",
8546 "/tmp/sql-adapter",
8547 )],
8548 )
8549 .unwrap()
8550 .build_router();
8551 let response = get(router, "/health").await;
8552 assert_eq!(response.status(), AxumStatusCode::OK);
8553 let body = response_json(response).await;
8554 assert_eq!(body["lora"]["enabled"], true);
8555 assert_eq!(body["lora"]["adapter_count"], 1);
8556 assert_eq!(body["lora"]["projection_applications"], 7);
8557 assert_eq!(body["lora"]["position"], "real-inference");
8558 assert_eq!(body["lora"]["source"], "test-lora");
8559 }
8560
8561 #[tokio::test]
8562 async fn route_health_includes_engine_execution_attribution_snapshot() {
8563 let router = AxumServer::from_llm(Arc::new(StubLlm::with_execution_attribution(
8564 "ok",
8565 json!({
8566 "schema": "ferrum.vnext.provider-attribution.v1",
8567 "attribution_basis": "resolved_plan_and_completed_static_initialization",
8568 "provider_attribution": {
8569 "expected_quant_tensor_count": 400,
8570 "attributed_quant_tensor_count": 400,
8571 "expected_operation_count": 3,
8572 "attributed_operation_count": 3,
8573 "expected_item_count": 403,
8574 "attributed_item_count": 403,
8575 "percent": 100.0,
8576 "denominator_sha256": "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8577 },
8578 "fallback_counts": {"silent": 0, "dense": 0, "legacy": 0}
8579 }),
8580 )))
8581 .build_router();
8582 let response = get(router, "/health").await;
8583 assert_eq!(response.status(), AxumStatusCode::OK);
8584 let body = response_json(response).await;
8585 assert_eq!(
8586 body["execution_attribution"]["provider_attribution"]["expected_item_count"],
8587 403
8588 );
8589 assert_eq!(
8590 body["execution_attribution"]["provider_attribution"]["denominator_sha256"],
8591 "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8592 );
8593 assert_eq!(
8594 body["execution_attribution"]["fallback_counts"],
8595 json!({"silent": 0, "dense": 0, "legacy": 0})
8596 );
8597 }
8598
8599 #[tokio::test]
8600 async fn route_models_lists_loaded_stub_model() {
8601 let response = get(router_with_stub("ok"), "/v1/models").await;
8602 assert_eq!(response.status(), AxumStatusCode::OK);
8603 let body = response_json(response).await;
8604 assert_eq!(body["object"], "list");
8605 let data = body["data"].as_array().expect("models data array");
8606 assert_eq!(data.len(), 1, "body: {body}");
8607 assert_eq!(data[0]["id"], "stub-model");
8608 assert_eq!(data[0]["object"], "model");
8609 assert_eq!(data[0]["owned_by"], "ferrum");
8610 assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
8611 assert_eq!(data[0]["modalities"], json!(["text"]));
8612 assert!(data[0]["permission"].as_array().unwrap().is_empty());
8613 assert!(data[0]["root"].is_null());
8614 assert!(data[0]["parent"].is_null());
8615 assert!(data[0].get("max_model_len").is_none());
8616 }
8617
8618 #[tokio::test]
8619 async fn route_chat_public_alias_maps_to_internal_model_and_is_echoed() {
8620 let engine = Arc::new(CapturingLlm::new());
8621 let registry = ServedModelRegistry::try_new(
8622 "qwen3",
8623 ServedModelKind::Llm,
8624 vec!["served-alias".to_string(), "secondary-alias".to_string()],
8625 vec![],
8626 )
8627 .unwrap();
8628 let router = AxumServer::from_llm(engine.clone())
8629 .with_served_model_registry(registry)
8630 .build_router();
8631 let response = post_json(
8632 router,
8633 "/v1/chat/completions",
8634 json!({
8635 "model": "secondary-alias",
8636 "messages": [{"role": "user", "content": "Say hi"}],
8637 "max_tokens": 8
8638 }),
8639 )
8640 .await;
8641
8642 assert_eq!(response.status(), AxumStatusCode::OK);
8643 let body = response_json(response).await;
8644 assert_eq!(body["model"], "secondary-alias");
8645 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
8646 }
8647
8648 #[tokio::test]
8649 async fn route_models_lists_public_aliases_without_internal_model_id() {
8650 let registry = ServedModelRegistry::try_new(
8651 "qwen3",
8652 ServedModelKind::Llm,
8653 vec!["served-alias".to_string(), "secondary-alias".to_string()],
8654 vec![],
8655 )
8656 .unwrap();
8657 let router = AxumServer::from_llm(Arc::new(CapturingLlm::new()))
8658 .with_served_model_registry(registry)
8659 .build_router();
8660 let body = response_json(get(router, "/v1/models").await).await;
8661 let ids = body["data"]
8662 .as_array()
8663 .unwrap()
8664 .iter()
8665 .map(|entry| entry["id"].as_str().unwrap())
8666 .collect::<Vec<_>>();
8667
8668 assert_eq!(ids, vec!["served-alias", "secondary-alias"]);
8669 assert!(!ids.contains(&"qwen3"));
8670 assert!(body["data"]
8671 .as_array()
8672 .unwrap()
8673 .iter()
8674 .all(|entry| entry["modalities"] == json!(["text"])));
8675 }
8676
8677 #[tokio::test]
8678 async fn route_models_lists_embedding_registry_capabilities() {
8679 let body = response_json(get(router_with_stub_embed(), "/v1/models").await).await;
8680 let data = body["data"].as_array().unwrap();
8681
8682 assert_eq!(data.len(), 1);
8683 assert_eq!(data[0]["id"], "stub-embed");
8684 assert_eq!(data[0]["modalities"], json!(["text", "image"]));
8685 assert!(data[0].get("max_model_len").is_none());
8686 }
8687
8688 #[tokio::test]
8689 async fn route_models_reports_engine_capacity_for_public_aliases_and_adapters() {
8690 let capacity = 3072;
8691 let engine = StubLlm {
8692 context_capacity: Some(capacity),
8693 ..StubLlm::new("ok")
8694 };
8695 let registry = ServedModelRegistry::try_new(
8696 "stub-model",
8697 ServedModelKind::Llm,
8698 vec!["public-model".to_owned(), "second-alias".to_owned()],
8699 vec![LoraAdapterModel::new(
8700 "sql",
8701 "public-model:sql",
8702 "/tmp/adapter",
8703 )],
8704 )
8705 .unwrap();
8706 let router = AxumServer::from_llm(Arc::new(engine))
8707 .with_served_model_registry(registry)
8708 .build_router();
8709 let body = response_json(get(router, "/v1/models").await).await;
8710 let entries = body["data"].as_array().unwrap();
8711 assert_eq!(entries.len(), 3);
8712 for entry in entries {
8713 assert_eq!(entry["max_model_len"], capacity);
8714 }
8715 }
8716
8717 #[tokio::test]
8718 async fn route_models_lists_startup_lora_adapters() {
8719 let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
8720 .with_lora_adapters(
8721 "stub-model",
8722 vec![LoraAdapterModel::new(
8723 "sql",
8724 "stub-model:sql",
8725 "/tmp/sql-adapter",
8726 )],
8727 )
8728 .unwrap()
8729 .build_router();
8730 let response = get(router, "/v1/models").await;
8731 assert_eq!(response.status(), AxumStatusCode::OK);
8732 let body = response_json(response).await;
8733 let data = body["data"].as_array().expect("models data array");
8734 let ids: Vec<_> = data
8735 .iter()
8736 .map(|item| item["id"].as_str().unwrap_or_default())
8737 .collect();
8738 assert!(ids.contains(&"stub-model"), "body: {body}");
8739 assert!(ids.contains(&"stub-model:sql"), "body: {body}");
8740 let adapter = data
8741 .iter()
8742 .find(|item| item["id"] == "stub-model:sql")
8743 .expect("adapter model");
8744 assert_eq!(adapter["root"], "stub-model");
8745 assert_eq!(adapter["parent"], "stub-model");
8746 assert_eq!(adapter["modalities"], json!(["text"]));
8747 }
8748
8749 #[tokio::test]
8750 async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
8751 let (router, engine) = router_with_capturing_lora_llm();
8752 let response = post_json(
8753 router,
8754 "/v1/chat/completions",
8755 json!({
8756 "model": "qwen3:sql",
8757 "messages": [{"role": "user", "content": "Say hi"}],
8758 "max_tokens": 8,
8759 "temperature": 0.0
8760 }),
8761 )
8762 .await;
8763 assert_eq!(response.status(), AxumStatusCode::OK);
8764 let body = response_json(response).await;
8765 assert_eq!(body["model"], "qwen3:sql");
8766 let captured = engine.last_request();
8767 assert_eq!(captured.model_id, ModelId::new("qwen3"));
8768 assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
8769 assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
8770 }
8771
8772 #[tokio::test]
8773 async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
8774 let (router, engine) = router_with_capturing_lora_llm();
8775 let response = post_json(
8776 router,
8777 "/v1/chat/completions",
8778 json!({
8779 "model": "qwen3",
8780 "messages": [{"role": "user", "content": "Say hi"}],
8781 "max_tokens": 8,
8782 "temperature": 0.0
8783 }),
8784 )
8785 .await;
8786 assert_eq!(response.status(), AxumStatusCode::OK);
8787 let captured = engine.last_request();
8788 assert_eq!(captured.model_id, ModelId::new("qwen3"));
8789 assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
8790 }
8791
8792 #[tokio::test]
8793 async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
8794 let (router, _) = router_with_capturing_lora_llm();
8795 let response = post_json(
8796 router,
8797 "/v1/chat/completions",
8798 json!({
8799 "model": "qwen3:missing",
8800 "messages": [{"role": "user", "content": "Say hi"}],
8801 "max_tokens": 8
8802 }),
8803 )
8804 .await;
8805 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8806 let body = response_json(response).await;
8807 assert_eq!(body["error"]["type"], "invalid_request_error");
8808 assert_eq!(body["error"]["param"], "model");
8809 assert!(
8810 body["error"]["message"]
8811 .as_str()
8812 .unwrap_or_default()
8813 .contains("unknown model"),
8814 "body: {body}"
8815 );
8816 }
8817
8818 #[tokio::test]
8819 async fn route_chat_unknown_served_model_returns_openai_model_error() {
8820 let engine = Arc::new(CapturingLlm::new());
8821 let router = AxumServer::from_llm(engine.clone()).build_router();
8822 let response = post_json(
8823 router,
8824 "/v1/chat/completions",
8825 json!({
8826 "model": "not-a-loaded-model",
8827 "messages": [{"role": "user", "content": "Say hi"}],
8828 "max_tokens": 8
8829 }),
8830 )
8831 .await;
8832 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8833 let body = response_json(response).await;
8834 assert_eq!(body["error"]["type"], "invalid_request_error");
8835 assert_eq!(body["error"]["param"], "model");
8836 assert!(
8837 body["error"]["message"]
8838 .as_str()
8839 .unwrap_or_default()
8840 .contains("unknown model"),
8841 "body: {body}"
8842 );
8843 assert!(!engine.has_captured_request());
8844 }
8845
8846 #[tokio::test]
8847 async fn route_models_without_engine_returns_empty_list() {
8848 let response = get(router_without_llm(), "/v1/models").await;
8849 assert_eq!(response.status(), AxumStatusCode::OK);
8850 let body = response_json(response).await;
8851 assert_eq!(body["object"], "list");
8852 assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
8853 }
8854
8855 #[tokio::test]
8856 async fn route_basic_chat_contract_uses_stub_engine() {
8857 let response = post_json(
8858 router_with_stub("hello"),
8859 "/v1/chat/completions",
8860 json!({
8861 "model": "stub-model",
8862 "messages": [{"role": "user", "content": "Say hi"}],
8863 "max_tokens": 8,
8864 "temperature": 0.0
8865 }),
8866 )
8867 .await;
8868 assert_eq!(response.status(), AxumStatusCode::OK);
8869 let body = response_json(response).await;
8870 assert_eq!(body["object"], "chat.completion");
8871 assert_eq!(body["choices"][0]["message"]["role"], "assistant");
8872 assert_eq!(body["choices"][0]["message"]["content"], "hello");
8873 assert_eq!(body["usage"]["prompt_tokens"], 7);
8874 assert_eq!(body["usage"]["completion_tokens"], 2);
8875 }
8876
8877 #[tokio::test]
8878 async fn route_chat_serializes_structured_tool_call_response() {
8879 let response = post_json(
8880 router_with_stub_api_response(
8881 "",
8882 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8883 message: ferrum_types::ApiChatMessage {
8884 role: ferrum_types::ApiMessageRole::Assistant,
8885 content: String::new(),
8886 name: None,
8887 tool_calls: vec![ferrum_types::ApiToolCall {
8888 id: "call_1".to_string(),
8889 tool_type: "function".to_string(),
8890 function: ferrum_types::ApiFunctionCall {
8891 name: "weather".to_string(),
8892 arguments: "{\"city\":\"Paris\"}".to_string(),
8893 },
8894 }],
8895 tool_call_id: None,
8896 function_call: None,
8897 },
8898 finish_reason: Some("tool_calls".to_string()),
8899 }),
8900 ),
8901 "/v1/chat/completions",
8902 json!({
8903 "model": "stub-model",
8904 "messages": [{"role": "user", "content": "Use the weather tool."}],
8905 "tools": [{
8906 "type": "function",
8907 "function": {
8908 "name": "weather",
8909 "parameters": {
8910 "type": "object",
8911 "properties": {"city": {"type": "string"}},
8912 "required": ["city"]
8913 }
8914 }
8915 }],
8916 "tool_choice": "auto"
8917 }),
8918 )
8919 .await;
8920 assert_eq!(response.status(), AxumStatusCode::OK);
8921 let body = response_json(response).await;
8922 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8923 assert_eq!(
8924 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8925 "weather"
8926 );
8927 assert_eq!(
8928 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8929 "{\"city\":\"Paris\"}"
8930 );
8931 }
8932
8933 #[tokio::test]
8934 async fn route_chat_preserves_length_over_structured_tool_response() {
8935 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
8936 let response = post_json(
8937 router_with_stub_api_response_and_finish_reason(
8938 generated,
8939 weather_tool_api_response(),
8940 FinishReason::Length,
8941 ),
8942 "/v1/chat/completions",
8943 json!({
8944 "model": "stub-model",
8945 "messages": [{"role": "user", "content": "Use the weather tool."}],
8946 "tools": [{
8947 "type": "function",
8948 "function": {"name": "weather", "parameters": {"type": "object"}}
8949 }],
8950 "tool_choice": "auto"
8951 }),
8952 )
8953 .await;
8954 assert_eq!(response.status(), AxumStatusCode::OK);
8955 let body = response_json(response).await;
8956 assert_eq!(body["choices"][0]["finish_reason"], "length");
8957 assert_eq!(body["choices"][0]["message"]["content"], generated);
8958 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
8959 }
8960
8961 #[tokio::test]
8962 async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
8963 let response = post_json(
8964 router_with_stub(
8965 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
8966 ),
8967 "/v1/chat/completions",
8968 json!({
8969 "model": "stub-model",
8970 "messages": [{"role": "user", "content": "Use the weather tool."}],
8971 "tools": [{
8972 "type": "function",
8973 "function": {
8974 "name": "weather",
8975 "parameters": {
8976 "type": "object",
8977 "properties": {"city": {"type": "string"}},
8978 "required": ["city"]
8979 }
8980 }
8981 }],
8982 "tool_choice": "auto"
8983 }),
8984 )
8985 .await;
8986 assert_eq!(response.status(), AxumStatusCode::OK);
8987 let body = response_json(response).await;
8988 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8989 assert_eq!(body["choices"][0]["message"]["content"], "");
8990 assert_eq!(
8991 body["choices"][0]["message"]["tool_calls"][0]["id"],
8992 "call_1"
8993 );
8994 assert_eq!(
8995 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8996 "weather"
8997 );
8998 assert_eq!(
8999 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9000 "{\"city\":\"Paris\"}"
9001 );
9002 }
9003
9004 #[tokio::test]
9005 async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
9006 let response = post_json(
9007 router_with_stub(
9008 r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
9009 ),
9010 "/v1/chat/completions",
9011 json!({
9012 "model": "stub-model",
9013 "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
9014 "tools": [{
9015 "type": "function",
9016 "function": {
9017 "name": "get_weather",
9018 "parameters": {
9019 "type": "object",
9020 "properties": {
9021 "city": {"type": "string"},
9022 "unit": {"type": "string", "enum": ["c", "f"]}
9023 },
9024 "required": ["city"]
9025 }
9026 }
9027 }],
9028 "tool_choice": "auto"
9029 }),
9030 )
9031 .await;
9032 assert_eq!(response.status(), AxumStatusCode::OK);
9033 let body = response_json(response).await;
9034 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9035 assert_eq!(body["choices"][0]["message"]["content"], "");
9036 assert_eq!(
9037 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9038 "get_weather"
9039 );
9040 assert_eq!(
9041 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9042 "{\"city\":\"北京\",\"unit\":\"c\"}"
9043 );
9044 }
9045
9046 #[tokio::test]
9047 async fn route_chat_uses_template_tool_protocol_for_function_parameter_xml() {
9048 let template = ModelChatTemplate::new(
9049 "{% if tools %}<tools>{{ tools | tojson }}</tools>Use <tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% endif %}",
9050 "function-parameter-xml-template",
9051 );
9052 let response = post_json(
9053 router_with_stub_and_template(
9054 "<tool_call>\n<function=get_weather>\n<parameter=city>\n北京\n</parameter>\n<parameter=unit>\ncelsius\n</parameter>\n</function>\n</tool_call>",
9055 template,
9056 ),
9057 "/v1/chat/completions",
9058 json!({
9059 "model": "stub-model",
9060 "messages": [{"role": "user", "content": "请调用 get_weather 查询北京天气。"}],
9061 "tools": [{
9062 "type": "function",
9063 "function": {
9064 "name": "get_weather",
9065 "parameters": {
9066 "type": "object",
9067 "properties": {
9068 "city": {"type": "string"},
9069 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9070 },
9071 "required": ["city"]
9072 }
9073 }
9074 }]
9075 }),
9076 )
9077 .await;
9078
9079 assert_eq!(response.status(), AxumStatusCode::OK);
9080 let body = response_json(response).await;
9081 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9082 assert_eq!(
9083 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9084 "get_weather"
9085 );
9086 assert_eq!(
9087 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9088 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9089 );
9090 }
9091
9092 fn xml_object_argument_tool_request(stream: bool) -> Value {
9093 json!({
9094 "model": "stub-model",
9095 "messages": [{"role": "user", "content": "Weather in Berlin with a forecast."}],
9096 "stream": stream,
9097 "tools": [{
9098 "type": "function",
9099 "function": {
9100 "name": "get_weather",
9101 "parameters": {
9102 "type": "object",
9103 "$defs": {
9104 "WeatherOptions": {
9105 "type": "object",
9106 "properties": {
9107 "unit": {"type": "string"},
9108 "include_forecast": {"type": "boolean"}
9109 },
9110 "required": ["unit", "include_forecast"],
9111 "additionalProperties": false
9112 }
9113 },
9114 "properties": {
9115 "city": {"type": "string"},
9116 "options": {"$ref": "#/$defs/WeatherOptions"}
9117 },
9118 "required": ["city", "options"],
9119 "additionalProperties": false
9120 }
9121 }
9122 }]
9123 })
9124 }
9125
9126 #[tokio::test]
9127 async fn route_chat_decodes_xml_object_argument_through_local_schema_ref() {
9128 let template = ModelChatTemplate::new(
9129 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9130 "function-parameter-xml-template",
9131 );
9132 let response = post_json(
9133 router_with_stub_and_template(
9134 concat!(
9135 "<tool_call>\n",
9136 "<function=get_weather>\n",
9137 "<parameter=city>\nBerlin\n</parameter>\n",
9138 "<parameter=options>\n",
9139 "{\"unit\":\"celsius\",\"include_forecast\":true}\n",
9140 "</parameter>\n",
9141 "</function>\n",
9142 "</tool_call>",
9143 ),
9144 template,
9145 ),
9146 "/v1/chat/completions",
9147 xml_object_argument_tool_request(false),
9148 )
9149 .await;
9150
9151 assert_eq!(response.status(), AxumStatusCode::OK);
9152 let body = response_json(response).await;
9153 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9154 let arguments = body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
9155 .as_str()
9156 .and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
9157 .expect("tool arguments must contain one-decode structured JSON");
9158 assert_eq!(arguments["city"], json!("Berlin"));
9159 assert_eq!(
9160 arguments["options"],
9161 json!({"unit": "celsius", "include_forecast": true})
9162 );
9163 }
9164
9165 #[tokio::test]
9166 async fn route_chat_rejects_malformed_native_xml_object_argument() {
9167 let template = ModelChatTemplate::new(
9168 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9169 "function-parameter-xml-template",
9170 );
9171 let mut request = xml_object_argument_tool_request(false);
9172 request["tools"][0]["function"]["strict"] = json!(true);
9173 let response = post_json(
9174 router_with_stub_and_template(
9175 concat!(
9176 "<tool_call><function=get_weather>",
9177 "<parameter=city>Berlin</parameter>",
9178 "<parameter=options>{\"unit\":\"celsius\",</parameter>",
9179 "</function></tool_call>",
9180 ),
9181 template,
9182 ),
9183 "/v1/chat/completions",
9184 request,
9185 )
9186 .await;
9187
9188 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9189 let body = response_json(response).await;
9190 assert_eq!(body["error"]["type"], "internal_server_error");
9191 assert!(
9192 body["error"]["message"]
9193 .as_str()
9194 .is_some_and(|message| message.contains("did not satisfy its schema")),
9195 "body: {body}"
9196 );
9197 }
9198
9199 #[tokio::test]
9200 async fn route_streaming_chat_rejects_malformed_native_xml_before_tool_delta() {
9201 let template = ModelChatTemplate::new(
9202 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9203 "function-parameter-xml-template",
9204 );
9205 let mut request = xml_object_argument_tool_request(true);
9206 request["tools"][0]["function"]["strict"] = json!(true);
9207 let response = post_json(
9208 router_with_stub_and_template(
9209 concat!(
9210 "<tool_call><function=get_weather>",
9211 "<parameter=city>Berlin</parameter>",
9212 "<parameter=options>{\"unit\":\"celsius\",</parameter>",
9213 "</function></tool_call>",
9214 ),
9215 template,
9216 ),
9217 "/v1/chat/completions",
9218 request,
9219 )
9220 .await;
9221
9222 assert_eq!(response.status(), AxumStatusCode::OK);
9223 let body = response_text(response).await;
9224 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9225 assert!(
9226 body.contains(r#""error":{"#) && body.contains("did not satisfy its schema"),
9227 "stream must return a controlled schema error: {body}"
9228 );
9229 assert!(
9230 !body.contains(r#""tool_calls":[{"#),
9231 "invalid native arguments must not leak a tool delta: {body}"
9232 );
9233 }
9234
9235 #[tokio::test]
9236 async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
9237 let response = post_json(
9238 router_with_stub(
9239 "kaza\n\
9240 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
9241 </think>\n\
9242 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
9243 {\"temperature\":25,\"condition\":\"晴\"}",
9244 ),
9245 "/v1/chat/completions",
9246 json!({
9247 "model": "stub-model",
9248 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
9249 "tools": [{
9250 "type": "function",
9251 "function": {
9252 "name": "get_weather",
9253 "parameters": {
9254 "type": "object",
9255 "properties": {
9256 "city": {"type": "string"},
9257 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9258 },
9259 "required": ["city"]
9260 }
9261 }
9262 }]
9263 }),
9264 )
9265 .await;
9266 assert_eq!(response.status(), AxumStatusCode::OK);
9267 let body = response_json(response).await;
9268 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9269 assert_eq!(body["choices"][0]["message"]["content"], "");
9270 assert_eq!(
9271 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9272 "get_weather"
9273 );
9274 assert_eq!(
9275 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9276 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9277 );
9278 }
9279
9280 #[tokio::test]
9281 async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
9282 let response = post_json(
9283 router_with_stub(
9284 "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
9285 </think>\n\
9286 {\"name\":\"get_weather\",\"arguments\":{}}",
9287 ),
9288 "/v1/chat/completions",
9289 json!({
9290 "model": "stub-model",
9291 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
9292 "tools": [{
9293 "type": "function",
9294 "function": {
9295 "name": "get_weather",
9296 "parameters": {
9297 "type": "object",
9298 "properties": {
9299 "city": {"type": "string"},
9300 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9301 },
9302 "required": ["city"]
9303 }
9304 }
9305 }]
9306 }),
9307 )
9308 .await;
9309 assert_eq!(response.status(), AxumStatusCode::OK);
9310 let body = response_json(response).await;
9311 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9312 assert_eq!(
9313 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9314 "get_weather"
9315 );
9316 assert_eq!(
9317 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9318 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9319 );
9320 }
9321
9322 #[tokio::test]
9323 async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
9324 let response = post_json(
9325 router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
9326 "/v1/chat/completions",
9327 json!({
9328 "model": "stub-model",
9329 "messages": [{"role": "user", "content": "Use the selected tool."}],
9330 "tools": [
9331 {
9332 "type": "function",
9333 "function": {"name": "weather", "parameters": {"type": "object"}}
9334 },
9335 {
9336 "type": "function",
9337 "function": {"name": "calendar", "parameters": {"type": "object"}}
9338 }
9339 ],
9340 "tool_choice": {
9341 "type": "function",
9342 "function": {"name": "weather"}
9343 }
9344 }),
9345 )
9346 .await;
9347 assert_eq!(response.status(), AxumStatusCode::OK);
9348 let body = response_json(response).await;
9349 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9350 assert_eq!(
9351 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9352 "weather"
9353 );
9354
9355 let response = post_json(
9356 router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
9357 "/v1/chat/completions",
9358 json!({
9359 "model": "stub-model",
9360 "messages": [{"role": "user", "content": "Use the selected tool."}],
9361 "tools": [
9362 {
9363 "type": "function",
9364 "function": {"name": "weather", "parameters": {"type": "object"}}
9365 },
9366 {
9367 "type": "function",
9368 "function": {"name": "calendar", "parameters": {"type": "object"}}
9369 }
9370 ],
9371 "tool_choice": {
9372 "type": "function",
9373 "function": {"name": "weather"}
9374 }
9375 }),
9376 )
9377 .await;
9378 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9379 let body = response_json(response).await;
9380 assert_eq!(body["error"]["param"], "tool_choice");
9381 assert_eq!(body["error"]["type"], "invalid_request_error");
9382 }
9383
9384 #[tokio::test]
9385 async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
9386 let response = post_json(
9387 router_with_stub(r#"{"city":"Paris"}"#),
9388 "/v1/chat/completions",
9389 json!({
9390 "model": "stub-model",
9391 "messages": [{"role": "user", "content": "Use the selected tool."}],
9392 "tools": [{
9393 "type": "function",
9394 "function": {
9395 "name": "weather",
9396 "parameters": {
9397 "type": "object",
9398 "properties": {"city": {"type": "string"}},
9399 "required": ["city"]
9400 }
9401 }
9402 }],
9403 "tool_choice": {
9404 "type": "function",
9405 "function": {"name": "weather"}
9406 }
9407 }),
9408 )
9409 .await;
9410 assert_eq!(response.status(), AxumStatusCode::OK);
9411 let body = response_json(response).await;
9412 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9413 assert_eq!(body["choices"][0]["message"]["content"], "");
9414 assert_eq!(
9415 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9416 "weather"
9417 );
9418 assert_eq!(
9419 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9420 "{\"city\":\"Paris\"}"
9421 );
9422 }
9423
9424 #[tokio::test]
9425 async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
9426 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9427 let response = post_json(
9428 router_with_stub(generated),
9429 "/v1/chat/completions",
9430 json!({
9431 "model": "stub-model",
9432 "messages": [{"role": "user", "content": "Do not use tools."}],
9433 "tools": [{
9434 "type": "function",
9435 "function": {"name": "weather", "parameters": {"type": "object"}}
9436 }],
9437 "tool_choice": "none"
9438 }),
9439 )
9440 .await;
9441 assert_eq!(response.status(), AxumStatusCode::OK);
9442 let body = response_json(response).await;
9443 assert_eq!(body["choices"][0]["finish_reason"], "stop");
9444 assert_eq!(body["choices"][0]["message"]["content"], generated);
9445 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
9446 }
9447
9448 #[tokio::test]
9449 async fn route_chat_tool_choice_required_wraps_generated_arguments() {
9450 let response = post_json(
9451 router_with_stub(r#"{"city":"Paris"}"#),
9452 "/v1/chat/completions",
9453 json!({
9454 "model": "stub-model",
9455 "messages": [{"role": "user", "content": "Use a tool."}],
9456 "tools": [{
9457 "type": "function",
9458 "function": {
9459 "name": "weather",
9460 "parameters": {
9461 "type": "object",
9462 "properties": {"city": {"type": "string"}},
9463 "required": ["city"]
9464 }
9465 }
9466 }],
9467 "tool_choice": "required"
9468 }),
9469 )
9470 .await;
9471 assert_eq!(response.status(), AxumStatusCode::OK);
9472 let body = response_json(response).await;
9473 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9474 assert_eq!(body["choices"][0]["message"]["content"], "");
9475 assert_eq!(
9476 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9477 "weather"
9478 );
9479 assert_eq!(
9480 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9481 "{\"city\":\"Paris\"}"
9482 );
9483 }
9484
9485 fn required_tool_with_strict_response_format_request(stream: bool) -> Value {
9486 json!({
9487 "model": "stub-model",
9488 "messages": [{"role": "user", "content": "Use the weather tool."}],
9489 "stream": stream,
9490 "stream_options": stream.then_some(json!({"include_usage": true})),
9491 "tools": [{
9492 "type": "function",
9493 "function": {
9494 "name": "weather",
9495 "parameters": {
9496 "type": "object",
9497 "properties": {"city": {"type": "string", "const": "Paris"}},
9498 "required": ["city"],
9499 "additionalProperties": false
9500 }
9501 }
9502 }],
9503 "tool_choice": "required",
9504 "response_format": {
9505 "type": "json_schema",
9506 "json_schema": {
9507 "name": "content_answer",
9508 "strict": true,
9509 "schema": {
9510 "type": "object",
9511 "properties": {"answer": {"type": "string", "const": "IGNORED"}},
9512 "required": ["answer"],
9513 "additionalProperties": false
9514 }
9515 }
9516 }
9517 })
9518 }
9519
9520 #[tokio::test]
9521 async fn route_chat_required_tool_takes_priority_over_strict_response_format() {
9522 let response = post_json(
9523 router_with_stub(r#"{"city":"Paris"}"#),
9524 "/v1/chat/completions",
9525 required_tool_with_strict_response_format_request(false),
9526 )
9527 .await;
9528 assert_eq!(response.status(), AxumStatusCode::OK);
9529 let body = response_json(response).await;
9530 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9531 assert_eq!(body["choices"][0]["message"]["content"], "");
9532 assert_eq!(
9533 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9534 "weather"
9535 );
9536 assert_eq!(
9537 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9538 r#"{"city":"Paris"}"#
9539 );
9540 }
9541
9542 #[tokio::test]
9543 async fn route_chat_required_tool_rejects_arguments_that_violate_const_schema() {
9544 let response = post_json(
9545 router_with_stub(r#"{"city":"London"}"#),
9546 "/v1/chat/completions",
9547 required_tool_with_strict_response_format_request(false),
9548 )
9549 .await;
9550 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9551 let body = response_json(response).await;
9552 assert_eq!(body["error"]["type"], "internal_server_error");
9553 assert!(
9554 body["error"]["message"]
9555 .as_str()
9556 .is_some_and(|message| message.contains("did not satisfy its schema")),
9557 "body: {body}"
9558 );
9559 }
9560
9561 #[tokio::test]
9562 async fn route_streaming_required_tool_takes_priority_over_strict_response_format() {
9563 let response = post_json(
9564 router_with_stub(r#"{"city":"Paris"}"#),
9565 "/v1/chat/completions",
9566 required_tool_with_strict_response_format_request(true),
9567 )
9568 .await;
9569 assert_eq!(response.status(), AxumStatusCode::OK);
9570 let body = response_text(response).await;
9571 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9572 assert!(
9573 body.contains(r#""finish_reason":"tool_calls""#),
9574 "tool priority must finish with tool_calls: {body}"
9575 );
9576 assert!(
9577 body.contains(r#""name":"weather""#)
9578 && body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9579 "stream must carry the reconstructed tool call: {body}"
9580 );
9581 assert_eq!(
9582 body.matches(r#""usage":{"#).count(),
9583 1,
9584 "stream must carry exactly one usage row: {body}"
9585 );
9586 assert!(
9587 !body.contains("strict json_schema") && !body.contains("invalid JSON"),
9588 "dormant content schema must not reject a required tool call: {body}"
9589 );
9590 }
9591
9592 #[tokio::test]
9593 async fn dropping_buffered_http_response_drops_the_engine_stream() {
9594 let stream_dropped = Arc::new(Notify::new());
9595 let response = post_json(
9596 AxumServer::from_llm(Arc::new(StubLlm::with_pending_stream(Arc::clone(
9597 &stream_dropped,
9598 ))))
9599 .build_router(),
9600 "/v1/chat/completions",
9601 required_tool_with_strict_response_format_request(true),
9602 )
9603 .await;
9604 assert_eq!(response.status(), AxumStatusCode::OK);
9605
9606 drop(response);
9607 tokio::time::timeout(std::time::Duration::from_secs(1), stream_dropped.notified())
9608 .await
9609 .expect("client disconnect must stop a buffered structured stream promptly");
9610 }
9611
9612 #[tokio::test]
9613 async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
9614 let response = post_json(
9615 router_with_stub("plain answer"),
9616 "/v1/chat/completions",
9617 json!({
9618 "model": "stub-model",
9619 "messages": [{"role": "user", "content": "Use a tool."}],
9620 "tools": [{
9621 "type": "function",
9622 "function": {"name": "weather", "parameters": {"type": "object"}}
9623 }],
9624 "tool_choice": "required"
9625 }),
9626 )
9627 .await;
9628 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9629 let body = response_json(response).await;
9630 assert_eq!(body["error"]["type"], "invalid_request_error");
9631 assert_eq!(body["error"]["param"], "tool_choice");
9632 assert!(
9633 body["error"]["message"]
9634 .as_str()
9635 .is_some_and(|message| message.contains("required tool_choice")),
9636 "body: {body}"
9637 );
9638 }
9639
9640 #[tokio::test]
9641 async fn route_streaming_chat_serializes_generated_tool_call_delta() {
9642 let response = post_json(
9643 router_with_stub(
9644 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
9645 ),
9646 "/v1/chat/completions",
9647 json!({
9648 "model": "stub-model",
9649 "messages": [{"role": "user", "content": "Use the weather tool."}],
9650 "stream": true,
9651 "tools": [{
9652 "type": "function",
9653 "function": {
9654 "name": "weather",
9655 "parameters": {
9656 "type": "object",
9657 "properties": {"city": {"type": "string"}},
9658 "required": ["city"]
9659 }
9660 }
9661 }],
9662 "tool_choice": "auto"
9663 }),
9664 )
9665 .await;
9666 assert_eq!(response.status(), AxumStatusCode::OK);
9667 let body = response_text(response).await;
9668 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9669 assert!(
9670 body.contains(r#""finish_reason":"tool_calls""#),
9671 "stream should finish with tool_calls: {body}"
9672 );
9673 assert!(
9674 body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
9675 "stream should emit OpenAI tool_calls delta with index: {body}"
9676 );
9677 assert!(
9678 body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9679 "tool arguments should be serialized as JSON string: {body}"
9680 );
9681 assert!(
9682 !body.contains(r#""content":"{\"tool_calls\""#),
9683 "raw tool-call JSON should not be streamed as assistant content: {body}"
9684 );
9685 }
9686
9687 #[tokio::test]
9688 async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
9689 let response = post_json(
9690 router_with_stub(
9691 r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
9692 ),
9693 "/v1/chat/completions",
9694 json!({
9695 "model": "stub-model",
9696 "messages": [{"role": "user", "content": "深圳天气?"}],
9697 "stream": true,
9698 "tools": [{
9699 "type": "function",
9700 "function": {
9701 "name": "get_weather",
9702 "parameters": {
9703 "type": "object",
9704 "properties": {
9705 "city": {"type": "string"},
9706 "unit": {"type": "string", "enum": ["c", "f"]}
9707 },
9708 "required": ["city"]
9709 }
9710 }
9711 }],
9712 "tool_choice": "auto"
9713 }),
9714 )
9715 .await;
9716 assert_eq!(response.status(), AxumStatusCode::OK);
9717 let body = response_text(response).await;
9718 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9719 assert!(
9720 body.contains(r#""finish_reason":"tool_calls""#),
9721 "stream should finish with tool_calls: {body}"
9722 );
9723 assert!(
9724 body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
9725 "stream should emit parsed Qwen3 function parameters as tool args: {body}"
9726 );
9727 assert!(
9728 !body.contains(r#""content":"{\"function\""#),
9729 "raw Qwen3 tool JSON should not leak as assistant content: {body}"
9730 );
9731 }
9732
9733 #[tokio::test]
9734 async fn route_streaming_chat_preserves_opencode_edit_xml_whitespace() {
9735 let template = ModelChatTemplate::new(
9736 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9737 "function-parameter-xml-template",
9738 );
9739 let generated = concat!(
9740 "<tool_call>\n",
9741 "<function=edit>\n",
9742 "<parameter=filePath>\n",
9743 "/workspace/src/main.rs\n",
9744 "</parameter>\n",
9745 "<parameter=oldString>\n",
9746 " if x:\n",
9747 " return 1\n",
9748 "\n",
9749 "</parameter>\n",
9750 "<parameter=newString>\n",
9751 " if x:\n",
9752 " return 2\n",
9753 "\n",
9754 "</parameter>\n",
9755 "<parameter=replaceAll>\n",
9756 "true\n",
9757 "</parameter>\n",
9758 "</function>\n",
9759 "</tool_call>",
9760 );
9761 let response = post_json(
9762 router_with_stub_and_template(generated, template),
9763 "/v1/chat/completions",
9764 json!({
9765 "model": "stub-model",
9766 "messages": [{"role": "user", "content": "Replace the code."}],
9767 "stream": true,
9768 "tools": [{
9769 "type": "function",
9770 "function": {
9771 "name": "edit",
9772 "parameters": {
9773 "type": "object",
9774 "properties": {
9775 "filePath": {"type": "string"},
9776 "oldString": {"type": "string"},
9777 "newString": {"type": "string"},
9778 "replaceAll": {"type": "boolean"}
9779 },
9780 "required": ["filePath", "oldString", "newString"]
9781 }
9782 }
9783 }]
9784 }),
9785 )
9786 .await;
9787
9788 assert_eq!(response.status(), AxumStatusCode::OK);
9789 let body = response_text(response).await;
9790 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9791 assert!(
9792 body.contains(r#"\"oldString\":\" if x:\\n return 1\\n\""#),
9793 "stream must preserve exact code whitespace in tool arguments: {body}"
9794 );
9795 assert!(
9796 body.contains(r#"\"replaceAll\":true"#),
9797 "stream must preserve the boolean tool argument type: {body}"
9798 );
9799 }
9800
9801 #[tokio::test]
9802 async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
9803 let request = |generated: &'static str| {
9804 post_json(
9805 router_with_stub(generated),
9806 "/v1/chat/completions",
9807 json!({
9808 "model": "stub-model",
9809 "messages": [{"role": "user", "content": "Use the selected tool."}],
9810 "stream": true,
9811 "tools": [
9812 {
9813 "type": "function",
9814 "function": {"name": "weather", "parameters": {"type": "object"}}
9815 },
9816 {
9817 "type": "function",
9818 "function": {"name": "calendar", "parameters": {"type": "object"}}
9819 }
9820 ],
9821 "tool_choice": {
9822 "type": "function",
9823 "function": {"name": "weather"}
9824 }
9825 }),
9826 )
9827 };
9828
9829 let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
9830 assert_eq!(response.status(), AxumStatusCode::OK);
9831 let body = response_text(response).await;
9832 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9833 assert!(
9834 body.contains(r#""finish_reason":"tool_calls""#),
9835 "selected tool should finish with tool_calls: {body}"
9836 );
9837 assert!(
9838 body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
9839 "selected tool should stream as tool_calls delta: {body}"
9840 );
9841
9842 let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
9843 assert_eq!(response.status(), AxumStatusCode::OK);
9844 let body = response_text(response).await;
9845 assert!(
9846 body.contains(
9847 r#""error":{"message":"model output did not satisfy required tool_choice""#
9848 ),
9849 "selected-tool stream should reject unselected tool output: {body}"
9850 );
9851 assert!(
9852 !body.contains(r#""finish_reason":"tool_calls""#),
9853 "unselected tool JSON must not become tool_calls: {body}"
9854 );
9855 }
9856
9857 #[tokio::test]
9858 async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
9859 let response = post_json(
9860 router_with_stub_api_response(
9861 "raw text that should not stream",
9862 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
9863 message: ferrum_types::ApiChatMessage {
9864 role: ferrum_types::ApiMessageRole::Assistant,
9865 content: String::new(),
9866 name: None,
9867 tool_calls: vec![ferrum_types::ApiToolCall {
9868 id: "call_1".to_string(),
9869 tool_type: "function".to_string(),
9870 function: ferrum_types::ApiFunctionCall {
9871 name: "weather".to_string(),
9872 arguments: "{\"city\":\"Paris\"}".to_string(),
9873 },
9874 }],
9875 tool_call_id: None,
9876 function_call: None,
9877 },
9878 finish_reason: Some("tool_calls".to_string()),
9879 }),
9880 ),
9881 "/v1/chat/completions",
9882 json!({
9883 "model": "stub-model",
9884 "messages": [{"role": "user", "content": "Use the weather tool."}],
9885 "stream": true,
9886 "tools": [{
9887 "type": "function",
9888 "function": {"name": "weather", "parameters": {"type": "object"}}
9889 }],
9890 "tool_choice": "auto"
9891 }),
9892 )
9893 .await;
9894 assert_eq!(response.status(), AxumStatusCode::OK);
9895 let body = response_text(response).await;
9896 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9897 assert!(
9898 body.contains(r#""finish_reason":"tool_calls""#),
9899 "stream should finish with tool_calls: {body}"
9900 );
9901 assert!(
9902 body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
9903 "stream should emit tool_calls from chunk api_response: {body}"
9904 );
9905 assert!(
9906 !body.contains("raw text that should not stream"),
9907 "structured api_response should suppress raw generated text in tool-call stream: {body}"
9908 );
9909 }
9910
9911 #[tokio::test]
9912 async fn route_streaming_chat_preserves_length_over_structured_tool_response() {
9913 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9914 let response = post_json(
9915 router_with_stub_api_response_and_finish_reason(
9916 generated,
9917 weather_tool_api_response(),
9918 FinishReason::Length,
9919 ),
9920 "/v1/chat/completions",
9921 json!({
9922 "model": "stub-model",
9923 "messages": [{"role": "user", "content": "Use the weather tool."}],
9924 "stream": true,
9925 "tools": [{
9926 "type": "function",
9927 "function": {"name": "weather", "parameters": {"type": "object"}}
9928 }],
9929 "tool_choice": "auto"
9930 }),
9931 )
9932 .await;
9933 assert_eq!(response.status(), AxumStatusCode::OK);
9934 let body = response_text(response).await;
9935 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9936 assert!(
9937 body.contains(r#""finish_reason":"length""#),
9938 "stream must preserve the engine terminal reason: {body}"
9939 );
9940 assert!(
9941 !body.contains(r#""finish_reason":"tool_calls""#),
9942 "length must not be relabeled as tool_calls: {body}"
9943 );
9944 }
9945
9946 #[tokio::test]
9947 async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
9948 let response = post_json(
9949 router_with_stub("plain answer"),
9950 "/v1/chat/completions",
9951 json!({
9952 "model": "stub-model",
9953 "messages": [{"role": "user", "content": "Use a tool."}],
9954 "stream": true,
9955 "tools": [{
9956 "type": "function",
9957 "function": {"name": "weather", "parameters": {"type": "object"}}
9958 }],
9959 "tool_choice": "required"
9960 }),
9961 )
9962 .await;
9963 assert_eq!(response.status(), AxumStatusCode::OK);
9964 let body = response_text(response).await;
9965 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9966 assert!(
9967 body.contains(
9968 r#""error":{"message":"model output did not satisfy required tool_choice""#
9969 ),
9970 "stream should emit OpenAI error envelope: {body}"
9971 );
9972 assert!(
9973 body.contains(r#""type":"invalid_request_error""#),
9974 "stream should use invalid_request_error: {body}"
9975 );
9976 assert!(
9977 body.contains(r#""param":"tool_choice""#),
9978 "stream should include tool_choice param: {body}"
9979 );
9980 assert!(
9981 !body.contains(r#""content":"plain answer""#),
9982 "required stream must not leak invalid content before validation: {body}"
9983 );
9984 }
9985
9986 #[tokio::test]
9987 async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
9988 let response = post_json(
9989 router_with_stub("plain answer"),
9990 "/v1/chat/completions",
9991 json!({
9992 "model": "stub-model",
9993 "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
9994 "stream": true,
9995 "tools": [{
9996 "type": "function",
9997 "function": {"name": "weather", "parameters": {"type": "object"}}
9998 }],
9999 "tool_choice": "auto"
10000 }),
10001 )
10002 .await;
10003 assert_eq!(response.status(), AxumStatusCode::OK);
10004 let body = response_text(response).await;
10005 assert!(
10006 body.contains(r#""content":"plain answer""#),
10007 "plain content should still stream when no tool call is generated: {body}"
10008 );
10009 assert!(
10010 body.contains(r#""finish_reason":"stop""#),
10011 "plain content should keep normal finish reason: {body}"
10012 );
10013 assert!(
10014 !body.contains(r#""tool_calls""#),
10015 "fallback content should not synthesize tool_calls: {body}"
10016 );
10017 }
10018
10019 #[tokio::test]
10020 async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
10021 let response = post_json(
10022 router_with_stub(
10023 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
10024 ),
10025 "/v1/chat/completions",
10026 json!({
10027 "model": "stub-model",
10028 "messages": [{"role": "user", "content": "Use the weather function."}],
10029 "stream": true,
10030 "functions": [{
10031 "name": "weather",
10032 "parameters": {
10033 "type": "object",
10034 "properties": {"city": {"type": "string"}},
10035 "required": ["city"]
10036 }
10037 }],
10038 "function_call": "auto"
10039 }),
10040 )
10041 .await;
10042 assert_eq!(response.status(), AxumStatusCode::OK);
10043 let body = response_text(response).await;
10044 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10045 assert!(
10046 body.contains(r#""finish_reason":"function_call""#),
10047 "stream should finish with function_call: {body}"
10048 );
10049 assert!(
10050 body.contains(
10051 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
10052 ),
10053 "stream should emit OpenAI legacy function_call delta: {body}"
10054 );
10055 assert!(
10056 !body.contains(r#""content":"{\"function_call\""#),
10057 "raw function-call JSON should not be streamed as assistant content: {body}"
10058 );
10059 }
10060
10061 #[tokio::test]
10062 async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
10063 let request = |generated: &'static str| {
10064 post_json(
10065 router_with_stub(generated),
10066 "/v1/chat/completions",
10067 json!({
10068 "model": "stub-model",
10069 "messages": [{"role": "user", "content": "Use the selected function."}],
10070 "stream": true,
10071 "functions": [
10072 {"name": "weather", "parameters": {"type": "object"}},
10073 {"name": "calendar", "parameters": {"type": "object"}}
10074 ],
10075 "function_call": {"name": "weather"}
10076 }),
10077 )
10078 };
10079
10080 let response =
10081 request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
10082 assert_eq!(response.status(), AxumStatusCode::OK);
10083 let body = response_text(response).await;
10084 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10085 assert!(
10086 body.contains(r#""finish_reason":"function_call""#),
10087 "selected function should finish with function_call: {body}"
10088 );
10089 assert!(
10090 body.contains(
10091 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
10092 ),
10093 "selected function should stream as function_call delta: {body}"
10094 );
10095
10096 let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
10097 assert_eq!(response.status(), AxumStatusCode::OK);
10098 let body = response_text(response).await;
10099 assert!(
10100 body.contains(
10101 r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
10102 ),
10103 "unselected function JSON should stream as ordinary content: {body}"
10104 );
10105 assert!(
10106 body.contains(r#""finish_reason":"stop""#),
10107 "unselected function JSON should keep normal stop finish: {body}"
10108 );
10109 assert!(
10110 !body.contains(r#""finish_reason":"function_call""#),
10111 "unselected function JSON must not become function_call: {body}"
10112 );
10113 }
10114
10115 #[tokio::test]
10116 async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
10117 let response = post_json(
10118 router_with_stub(
10119 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
10120 ),
10121 "/v1/chat/completions",
10122 json!({
10123 "model": "stub-model",
10124 "messages": [{"role": "user", "content": "Use the weather function."}],
10125 "functions": [{
10126 "name": "weather",
10127 "parameters": {
10128 "type": "object",
10129 "properties": {"city": {"type": "string"}},
10130 "required": ["city"]
10131 }
10132 }],
10133 "function_call": "auto"
10134 }),
10135 )
10136 .await;
10137 assert_eq!(response.status(), AxumStatusCode::OK);
10138 let body = response_json(response).await;
10139 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
10140 assert_eq!(body["choices"][0]["message"]["content"], "");
10141 assert_eq!(
10142 body["choices"][0]["message"]["function_call"]["name"],
10143 "weather"
10144 );
10145 assert_eq!(
10146 body["choices"][0]["message"]["function_call"]["arguments"],
10147 "{\"city\":\"Paris\"}"
10148 );
10149 }
10150
10151 #[tokio::test]
10152 async fn route_chat_serializes_legacy_function_call_response() {
10153 let response = post_json(
10154 router_with_stub_api_response(
10155 "",
10156 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
10157 message: ferrum_types::ApiChatMessage {
10158 role: ferrum_types::ApiMessageRole::Assistant,
10159 content: String::new(),
10160 name: None,
10161 tool_calls: vec![],
10162 tool_call_id: None,
10163 function_call: Some(ferrum_types::ApiFunctionCall {
10164 name: "weather".to_string(),
10165 arguments: "{\"city\":\"Paris\"}".to_string(),
10166 }),
10167 },
10168 finish_reason: Some("function_call".to_string()),
10169 }),
10170 ),
10171 "/v1/chat/completions",
10172 json!({
10173 "model": "stub-model",
10174 "messages": [{"role": "user", "content": "Use the weather function."}],
10175 "functions": [{
10176 "name": "weather",
10177 "parameters": {
10178 "type": "object",
10179 "properties": {"city": {"type": "string"}},
10180 "required": ["city"]
10181 }
10182 }],
10183 "function_call": "auto"
10184 }),
10185 )
10186 .await;
10187 assert_eq!(response.status(), AxumStatusCode::OK);
10188 let body = response_json(response).await;
10189 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
10190 assert_eq!(
10191 body["choices"][0]["message"]["function_call"]["name"],
10192 "weather"
10193 );
10194 assert_eq!(
10195 body["choices"][0]["message"]["function_call"]["arguments"],
10196 "{\"city\":\"Paris\"}"
10197 );
10198 }
10199
10200 #[tokio::test]
10201 async fn route_streaming_chat_include_usage_contract() {
10202 let response = post_json(
10203 router_with_stub("ok"),
10204 "/v1/chat/completions",
10205 json!({
10206 "model": "stub-model",
10207 "messages": [{"role": "user", "content": "Say ok"}],
10208 "stream": true,
10209 "stream_options": {"include_usage": true}
10210 }),
10211 )
10212 .await;
10213 assert_eq!(response.status(), AxumStatusCode::OK);
10214 let body = response_text(response).await;
10215 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10216 assert!(
10217 body.contains("\"object\":\"chat.completion.chunk\""),
10218 "missing chat chunk: {body}"
10219 );
10220 assert!(
10221 body.contains("\"usage\":{\"prompt_tokens\""),
10222 "missing final usage chunk: {body}"
10223 );
10224 assert!(
10225 body.contains("\"choices\":[],\"usage\""),
10226 "usage should be emitted as a separate chunk: {body}"
10227 );
10228 assert!(
10229 body.contains("\"prompt_tokens\":5"),
10230 "stream usage should come from engine token usage: {body}"
10231 );
10232 }
10233
10234 #[tokio::test]
10235 async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
10236 let response = post_json(
10237 router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
10238 "/v1/chat/completions",
10239 json!({
10240 "model": "stub-model",
10241 "messages": [{"role": "user", "content": "Say hello"}],
10242 "max_tokens": 2,
10243 "stream": true,
10244 "stream_options": {"include_usage": true}
10245 }),
10246 )
10247 .await;
10248 assert_eq!(response.status(), AxumStatusCode::OK);
10249 let body = response_text(response).await;
10250 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10251 assert!(
10252 body.contains("\"content\":\"he\""),
10253 "missing first chunk: {body}"
10254 );
10255 assert!(
10256 body.contains("\"content\":\"llo\""),
10257 "missing second chunk: {body}"
10258 );
10259 assert!(
10260 body.contains("\"choices\":[],\"usage\""),
10261 "missing separate usage chunk from final engine chunk: {body}"
10262 );
10263 assert!(
10264 body.contains("\"prompt_tokens\":5"),
10265 "stream usage should come from engine final usage: {body}"
10266 );
10267 }
10268
10269 #[tokio::test]
10270 async fn route_streaming_preserves_tokenless_tail_before_terminal() {
10271 for (path, chunks, expected_content, expected_reasoning) in [
10272 ("/v1/chat/completions", ["hello ", "尾"], "hello 尾", ""),
10273 (
10274 "/v1/chat/completions",
10275 ["<think>reason", "</think>"],
10276 "",
10277 "reason",
10278 ),
10279 ("/v1/completions", ["hello ", "尾"], "hello 尾", ""),
10280 ] {
10281 let chat = path == "/v1/chat/completions";
10282 let mut request = json!({"model": "stub-model", "stream": true});
10283 if chat {
10284 request["messages"] = json!([{"role": "user", "content": "hello"}]);
10285 request["stream_options"] = json!({"include_usage": true});
10286 } else {
10287 request["prompt"] = json!("hello");
10288 }
10289 let router = AxumServer::from_llm(Arc::new(StubLlm::with_tokenless_tail(&chunks)))
10290 .build_router();
10291 let response = post_json(router, path, request).await;
10292 assert_eq!(response.status(), AxumStatusCode::OK);
10293 let body = response_text(response).await;
10294 let events = responses_sse_json_events(&body);
10295 let content: String = events
10296 .iter()
10297 .filter_map(|event| {
10298 if chat {
10299 event["choices"][0]["delta"]["content"].as_str()
10300 } else {
10301 event["choices"][0]["text"].as_str()
10302 }
10303 })
10304 .collect();
10305 let reasoning: String = events
10306 .iter()
10307 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
10308 .collect();
10309 assert_eq!(content, expected_content, "body: {body}");
10310 assert_eq!(reasoning, expected_reasoning, "body: {body}");
10311 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10312 assert_eq!(
10313 events
10314 .iter()
10315 .filter(|event| event["choices"][0]["finish_reason"] == "stop")
10316 .count(),
10317 1,
10318 "body: {body}"
10319 );
10320 let usage: Vec<_> = events
10321 .iter()
10322 .filter_map(|event| event["usage"].as_object())
10323 .collect();
10324 assert_eq!(usage.len(), 1, "body: {body}");
10325 assert_eq!(usage[0]["prompt_tokens"], 5);
10326 assert_eq!(usage[0]["completion_tokens"], 2);
10327 }
10328 }
10329
10330 #[tokio::test]
10331 async fn route_rejects_multimodal_content_with_400() {
10332 for content in [
10333 json!([{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}]),
10334 json!([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]),
10335 json!([{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}]),
10336 json!([
10337 {"type": "text", "text": "describe this"},
10338 {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
10339 ]),
10340 ] {
10341 let response = post_json(
10342 router_with_stub("unused"),
10343 "/v1/chat/completions",
10344 json!({
10345 "model": "stub-model",
10346 "messages": [{"role": "user", "content": content}]
10347 }),
10348 )
10349 .await;
10350 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10351 let body = response_json(response).await;
10352 assert_eq!(body["error"]["type"], "invalid_request_error");
10353 let message = body["error"]["message"].as_str().unwrap();
10354 assert!(message.contains("invalid chat completions request"));
10355 assert!(
10356 message.contains("unsupported message content part type"),
10357 "body: {body}"
10358 );
10359 }
10360 }
10361
10362 #[tokio::test]
10363 async fn route_rejects_non_object_stream_options() {
10364 for stream_options in [json!([]), json!("yes"), json!(42), json!(true)] {
10365 let response = post_json(
10366 router_with_stub("unused"),
10367 "/v1/chat/completions",
10368 json!({
10369 "model": "stub-model",
10370 "messages": [{"role": "user", "content": "hello"}],
10371 "stream": true,
10372 "stream_options": stream_options
10373 }),
10374 )
10375 .await;
10376 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10377 let body = response_json(response).await;
10378 assert_eq!(body["error"]["type"], "invalid_request_error");
10379 assert!(
10380 body["error"]["message"]
10381 .as_str()
10382 .unwrap_or_default()
10383 .contains("stream_options must be a JSON object"),
10384 "body: {body}"
10385 );
10386 }
10387 }
10388
10389 #[tokio::test]
10390 async fn route_accepts_text_only_content_array() {
10391 let response = post_json(
10392 router_with_stub("ok"),
10393 "/v1/chat/completions",
10394 json!({
10395 "model": "stub-model",
10396 "messages": [{
10397 "role": "user",
10398 "content": [
10399 {"type": "text", "text": "say"},
10400 {"type": "text", "text": "ok"}
10401 ]
10402 }]
10403 }),
10404 )
10405 .await;
10406 assert_eq!(response.status(), AxumStatusCode::OK);
10407 let body = response_json(response).await;
10408 assert_eq!(body["choices"][0]["message"]["content"], "ok");
10409 }
10410
10411 #[tokio::test]
10412 async fn route_chat_invalid_json_maps_to_openai_error() {
10413 let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
10414 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10415 let body = response_json(response).await;
10416 assert_eq!(body["error"]["type"], "invalid_request_error");
10417 assert_eq!(body["error"]["param"], Value::Null);
10418 assert!(body["error"]["message"]
10419 .as_str()
10420 .unwrap()
10421 .contains("invalid chat completions request"));
10422 }
10423
10424 #[tokio::test]
10425 async fn route_rejects_logit_bias_with_openai_error_param() {
10426 let response = post_json(
10427 router_with_stub("unused"),
10428 "/v1/chat/completions",
10429 json!({
10430 "model": "stub-model",
10431 "messages": [{"role": "user", "content": "hello"}],
10432 "logit_bias": {"1": 42.0}
10433 }),
10434 )
10435 .await;
10436 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10437 let body = response_json(response).await;
10438 assert_eq!(body["error"]["type"], "invalid_request_error");
10439 assert_eq!(body["error"]["param"], "logit_bias");
10440 }
10441
10442 #[tokio::test]
10443 async fn route_tool_request_reaches_engine_structured_boundary() {
10444 for stream in [false, true] {
10445 let (router, engine) = router_with_capturing_llm();
10446 let response = post_json(
10447 router,
10448 "/v1/chat/completions",
10449 json!({
10450 "model": "qwen3",
10451 "messages": [
10452 {"role": "user", "content": "Use the weather tool."},
10453 {
10454 "role": "assistant",
10455 "content": null,
10456 "tool_calls": [{
10457 "id": "call_1",
10458 "type": "function",
10459 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10460 }]
10461 },
10462 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
10463 ],
10464 "tools": [{
10465 "type": "function",
10466 "function": {
10467 "name": "weather",
10468 "description": "Get weather",
10469 "parameters": {
10470 "type": "object",
10471 "properties": {"city": {"type": "string"}},
10472 "required": ["city"]
10473 }
10474 }
10475 }],
10476 "tool_choice": "auto",
10477 "functions": [{
10478 "name": "legacy_weather",
10479 "parameters": {"type": "object", "properties": {}}
10480 }],
10481 "function_call": "auto",
10482 "stream": stream
10483 }),
10484 )
10485 .await;
10486 assert_eq!(response.status(), AxumStatusCode::OK);
10487
10488 if stream {
10489 let body = response_text(response).await;
10490 assert!(body.contains("[DONE]"), "{body}");
10491 assert!(body.contains("captured"), "{body}");
10492 } else {
10493 let body = response_json(response).await;
10494 assert_eq!(body["choices"][0]["message"]["content"], "captured");
10495 assert_eq!(body["choices"][0]["finish_reason"], "stop");
10496 }
10497 let request = engine.last_request();
10498 assert!(request.prompt.contains("\"tools\":[{"));
10499 assert!(request.prompt.contains("\"type\":\"function\""));
10500 assert!(request.prompt.contains("\"name\":\"weather\""));
10501 assert!(request.prompt.contains("<|im_start|>assistant\n{"));
10502 assert!(request.prompt.contains("\"tool_calls\":[{"));
10503 assert!(request.prompt.contains("\"id\":\"call_1\""));
10504 assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
10505 assert_eq!(
10506 request.metadata["openai_tools"][0]["function"]["name"],
10507 "weather"
10508 );
10509 assert_eq!(request.metadata["openai_tool_choice"], "auto");
10510 assert_eq!(
10511 request.metadata["openai_legacy_functions"][0]["name"],
10512 "legacy_weather"
10513 );
10514 assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
10515 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10516 panic!("expected structured chat api_request");
10517 };
10518 assert_eq!(api.messages.len(), 3);
10519 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
10520 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
10521 assert_eq!(api.messages[1].tool_calls[0].id, "call_1");
10522 assert_eq!(api.messages[1].tool_calls[0].function.name, "weather");
10523 assert_eq!(api.messages[2].content, "sunny");
10524 assert_eq!(api.tools[0].function.name, "weather");
10525 assert_eq!(api.legacy_functions[0].name, "legacy_weather");
10526 assert_eq!(
10527 api.messages[1].tool_calls[0].function.arguments,
10528 "{\"city\":\"Paris\"}"
10529 );
10530 }
10531 }
10532
10533 #[tokio::test]
10534 async fn route_replays_reasoning_content_in_qwen36_tool_history_sync() {
10535 let compatibility = capture_qwen36_tool_history_request(
10536 json!({"reasoning_content": "opencode-reasoning-marker"}),
10537 false,
10538 )
10539 .await;
10540 let canonical = capture_qwen36_tool_history_request(
10541 json!({"reasoning": "opencode-reasoning-marker"}),
10542 false,
10543 )
10544 .await;
10545
10546 assert_eq!(compatibility.prompt, canonical.prompt);
10547 assert!(
10548 compatibility.prompt.contains("opencode-reasoning-marker"),
10549 "Qwen3.6 prompt dropped assistant reasoning history: {}",
10550 compatibility.prompt
10551 );
10552 let message = &compatibility.metadata["openai_messages"][1];
10553 assert_eq!(message["reasoning"], "opencode-reasoning-marker");
10554 assert!(message.get("reasoning_content").is_none());
10555 }
10556
10557 #[tokio::test]
10558 async fn route_replays_reasoning_content_in_qwen36_tool_history_stream() {
10559 let request = capture_qwen36_tool_history_request(
10560 json!({"reasoning_content": "opencode-stream-reasoning-marker"}),
10561 true,
10562 )
10563 .await;
10564 assert!(
10565 request.prompt.contains("opencode-stream-reasoning-marker"),
10566 "Qwen3.6 streaming prompt dropped assistant reasoning history: {}",
10567 request.prompt
10568 );
10569 }
10570
10571 #[tokio::test]
10572 async fn route_prefers_canonical_reasoning_in_qwen36_tool_history() {
10573 for stream in [false, true] {
10574 for reasoning in ["canonical-history-marker", ""] {
10575 let request = capture_qwen36_tool_history_request(
10576 json!({
10577 "reasoning": reasoning,
10578 "reasoning_content": "alias-history-marker"
10579 }),
10580 stream,
10581 )
10582 .await;
10583 let canonical =
10584 capture_qwen36_tool_history_request(json!({"reasoning": reasoning}), stream)
10585 .await;
10586 assert_eq!(request.prompt, canonical.prompt);
10587 assert!(!request.prompt.contains("alias-history-marker"));
10588 let message = &request.metadata["openai_messages"][1];
10589 assert_eq!(message["reasoning"], reasoning);
10590 assert!(message.get("reasoning_content").is_none());
10591 }
10592 }
10593 }
10594
10595 #[tokio::test]
10596 async fn route_does_not_force_reasoning_into_templates_that_ignore_it() {
10597 let template = ModelChatTemplate::new(
10598 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}",
10599 "content-only-template",
10600 );
10601 let (router, engine) = router_with_capturing_llm_and_template(template);
10602 let response = post_json(
10603 router,
10604 "/v1/chat/completions",
10605 json!({
10606 "model": "served-alias",
10607 "messages": [
10608 {"role": "user", "content": "hello"},
10609 {
10610 "role": "assistant",
10611 "content": "visible answer",
10612 "reasoning_content": "hidden-reasoning-marker"
10613 },
10614 {"role": "user", "content": "continue"}
10615 ]
10616 }),
10617 )
10618 .await;
10619 assert_eq!(response.status(), AxumStatusCode::OK);
10620
10621 let request = engine.last_request();
10622 assert!(request.prompt.contains("visible answer"));
10623 assert!(!request.prompt.contains("hidden-reasoning-marker"));
10624 }
10625
10626 #[tokio::test]
10627 async fn route_tool_request_prefers_model_chat_template() {
10628 for stream in [false, true] {
10629 let template = ModelChatTemplate::new(
10630 "{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% if message.tool_calls %}{% for tool_call in message.tool_calls %}<tool_call id=\"{{ tool_call.id }}\">{{ tool_call.function.name }}:{{ tool_call.function.arguments }}</tool_call>{% endfor %}{% endif %}{% if message.tool_call_id %}<tool_response id=\"{{ message.tool_call_id }}\">{{ message.content }}</tool_response>{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
10631 "tool-template",
10632 );
10633 let (router, engine) = router_with_capturing_llm_and_template(template);
10634 let response = post_json(
10635 router,
10636 "/v1/chat/completions",
10637 json!({
10638 "model": "served-alias",
10639 "messages": [
10640 {"role": "user", "content": "Use the weather tool."},
10641 {
10642 "role": "assistant",
10643 "content": null,
10644 "tool_calls": [{
10645 "id": "weather_paris",
10646 "type": "function",
10647 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10648 }, {
10649 "id": "weather_rome",
10650 "type": "function",
10651 "function": {"name": "weather", "arguments": "{\"city\":\"Rome\"}"}
10652 }]
10653 },
10654 {"role": "tool", "tool_call_id": "weather_rome", "content": "rainy"},
10657 {"role": "tool", "tool_call_id": "weather_paris", "content": "sunny"}
10658 ],
10659 "tools": [{
10660 "type": "function",
10661 "function": {
10662 "name": "weather",
10663 "description": "Get weather",
10664 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
10665 }
10666 }],
10667 "tool_choice": "auto",
10668 "stream": stream
10669 }),
10670 )
10671 .await;
10672 assert_eq!(response.status(), AxumStatusCode::OK);
10673
10674 if stream {
10675 let body = response_text(response).await;
10676 assert!(body.contains("[DONE]"), "{body}");
10677 assert!(body.contains("captured"), "{body}");
10678 } else {
10679 let body = response_json(response).await;
10680 assert_eq!(body["choices"][0]["message"]["content"], "captured");
10681 assert_eq!(body["choices"][0]["finish_reason"], "stop");
10682 }
10683 let request = engine.last_request();
10684 assert!(request.prompt.contains("<tools>weather</tools>"));
10685 assert!(
10686 request
10687 .prompt
10688 .contains("<tool_call id=\"weather_paris\">weather:"),
10689 "{}",
10690 request.prompt
10691 );
10692 assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
10693 assert!(request.prompt.contains("Paris"), "{}", request.prompt);
10694 assert!(request
10695 .prompt
10696 .contains("<tool_response id=\"weather_paris\">sunny</tool_response>"));
10697 assert!(request
10698 .prompt
10699 .contains("<tool_call id=\"weather_rome\">weather:"));
10700 assert!(request.prompt.contains("Rome"));
10701 assert!(request
10702 .prompt
10703 .contains("<tool_response id=\"weather_rome\">rainy</tool_response>"));
10704 assert!(request.prompt.ends_with("[assistant]"));
10705 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10706 panic!("expected structured continuation request");
10707 };
10708 assert_eq!(api.messages.len(), 4);
10709 assert_eq!(api.messages[1].tool_calls.len(), 2);
10710 for (call, id, city) in [
10711 (&api.messages[1].tool_calls[0], "weather_paris", "Paris"),
10712 (&api.messages[1].tool_calls[1], "weather_rome", "Rome"),
10713 ] {
10714 assert_eq!(call.id, id);
10715 assert_eq!(call.function.name, "weather");
10716 let args: Value = serde_json::from_str(&call.function.arguments).unwrap();
10717 assert_eq!(args, json!({"city": city}));
10718 let prefix = format!("<tool_call id=\"{id}\">weather:");
10719 let rendered_arguments = request
10720 .prompt
10721 .split_once(&prefix)
10722 .unwrap()
10723 .1
10724 .split_once("</tool_call>")
10725 .unwrap()
10726 .0;
10727 let rendered: Value = serde_json::from_str(rendered_arguments).unwrap();
10728 assert_eq!(
10729 rendered,
10730 json!({"city": city}),
10731 "tool arguments lost their call ID binding"
10732 );
10733 }
10734 for (message, id, content) in [
10735 (&api.messages[2], "weather_rome", "rainy"),
10736 (&api.messages[3], "weather_paris", "sunny"),
10737 ] {
10738 assert_eq!(message.role, ferrum_types::ApiMessageRole::Tool);
10739 assert_eq!(message.tool_call_id.as_deref(), Some(id));
10740 assert_eq!(message.content, content);
10741 }
10742 assert!(
10743 !request.prompt.contains("<|assistant|>"),
10744 "model-template tool prompt should not use generic fallback: {}",
10745 request.prompt
10746 );
10747 assert!(
10748 !request.prompt.contains("When a tool is needed"),
10749 "model-template tool prompt should not inject fallback tool instructions: {}",
10750 request.prompt
10751 );
10752 }
10753 }
10754
10755 #[tokio::test]
10756 async fn chat_omitted_output_budget_uses_auto_ceiling() {
10757 let (router, engine) = router_with_capturing_llm();
10758 let response = post_json(
10759 router,
10760 "/v1/chat/completions",
10761 json!({
10762 "model": "stub-model",
10763 "messages": [{"role": "user", "content": "hello"}]
10764 }),
10765 )
10766 .await;
10767 assert_eq!(response.status(), AxumStatusCode::OK);
10768
10769 let request = engine.last_request();
10770 assert_eq!(request.sampling_params.max_tokens, 4096);
10771 assert_eq!(
10772 request.metadata.get(DEFAULT_MAX_TOKENS_METADATA_KEY),
10773 Some(&serde_json::json!(true))
10774 );
10775 }
10776
10777 #[tokio::test]
10778 async fn chat_accepts_stop_string_and_max_completion_tokens() {
10779 let (router, engine) = router_with_capturing_llm();
10780 let response = post_json(
10781 router,
10782 "/v1/chat/completions",
10783 json!({
10784 "model": "stub-model",
10785 "messages": [{"role": "user", "content": "hello"}],
10786 "max_tokens": 99,
10787 "max_completion_tokens": 3,
10788 "stop": "<END>"
10789 }),
10790 )
10791 .await;
10792 assert_eq!(response.status(), AxumStatusCode::OK);
10793
10794 let request = engine.last_request();
10795 let defaults = default_chat_sampling_params();
10796 assert_eq!(request.sampling_params.max_tokens, 3);
10797 assert!(!request
10798 .metadata
10799 .contains_key(DEFAULT_MAX_TOKENS_METADATA_KEY));
10800 assert_eq!(request.sampling_params.temperature, defaults.temperature);
10801 assert_eq!(
10802 request.sampling_params.repetition_penalty,
10803 defaults.repetition_penalty
10804 );
10805 assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
10806 }
10807
10808 #[tokio::test]
10809 async fn chat_maps_vllm_sampling_extensions_without_hidden_defaults() {
10810 let (router, engine) = router_with_capturing_llm();
10811 let response = post_json(
10812 router,
10813 "/v1/chat/completions",
10814 json!({
10815 "model": "stub-model",
10816 "messages": [{"role": "user", "content": "hello"}],
10817 "top_k": 20,
10818 "min_p": 0.05,
10819 "repetition_penalty": 1.25
10820 }),
10821 )
10822 .await;
10823 assert_eq!(response.status(), AxumStatusCode::OK);
10824
10825 let request = engine.last_request();
10826 assert_eq!(request.sampling_params.top_k, Some(20));
10827 assert_eq!(request.sampling_params.min_p, Some(0.05));
10828 assert_eq!(request.sampling_params.repetition_penalty, 1.25);
10829 }
10830
10831 #[tokio::test]
10832 async fn chat_normalizes_disabled_sampling_extensions_and_rejects_invalid_ranges() {
10833 let (router, engine) = router_with_capturing_llm();
10834 let response = post_json(
10835 router,
10836 "/v1/chat/completions",
10837 json!({
10838 "model": "stub-model",
10839 "messages": [{"role": "user", "content": "hello"}],
10840 "top_k": -1,
10841 "min_p": 0.0,
10842 "repetition_penalty": 1.0
10843 }),
10844 )
10845 .await;
10846 assert_eq!(response.status(), AxumStatusCode::OK);
10847 let request = engine.last_request();
10848 assert_eq!(request.sampling_params.top_k, None);
10849 assert_eq!(request.sampling_params.min_p, None);
10850 assert_eq!(request.sampling_params.repetition_penalty, 1.0);
10851
10852 for (field, value) in [
10853 ("top_k", json!(-2)),
10854 ("min_p", json!(1.01)),
10855 ("repetition_penalty", json!(0.0)),
10856 ("presence_penalty", json!(2.01)),
10857 ("frequency_penalty", json!(-2.01)),
10858 ] {
10859 let (router, _) = router_with_capturing_llm();
10860 let response = post_json(
10861 router,
10862 "/v1/chat/completions",
10863 json!({
10864 "model": "stub-model",
10865 "messages": [{"role": "user", "content": "hello"}],
10866 (field): value
10867 }),
10868 )
10869 .await;
10870 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST, "{field}");
10871 let body = response_json(response).await;
10872 assert_eq!(body["error"]["param"], field);
10873 }
10874 }
10875
10876 #[tokio::test]
10877 async fn chat_request_forbids_initial_think_close_token() {
10878 let engine = Arc::new(CapturingLlm::new());
10879 let router = AxumServer::from_llm(engine.clone()).build_router();
10880 let response = post_json(
10881 router,
10882 "/v1/chat/completions",
10883 json!({
10884 "model": "qwen3",
10885 "messages": [{"role": "user", "content": "hello"}]
10886 }),
10887 )
10888 .await;
10889 assert_eq!(response.status(), AxumStatusCode::OK);
10890
10891 let request = engine.last_request();
10892 assert_eq!(
10893 request
10894 .metadata
10895 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10896 Some(&serde_json::json!([THINK_END_TAG]))
10897 );
10898 }
10899
10900 #[tokio::test]
10901 async fn omitted_enable_thinking_preserves_model_template_default() {
10902 let template = ModelChatTemplate::new(
10903 "{% for message in messages %}{{ '<|im_start|>' ~ message.role ~ '\n' ~ message.content ~ '<|im_end|>\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% if enable_thinking is defined and enable_thinking is false %}{{ '<think>\n\n</think>\n\n' }}{% else %}{{ '<think>\n' }}{% endif %}{% endif %}",
10904 "test-template",
10905 );
10906 let (router, engine) = router_with_capturing_llm_and_template(template);
10907 let response = post_json(
10908 router,
10909 "/v1/chat/completions",
10910 json!({
10911 "model": "served-alias",
10912 "messages": [{"role": "user", "content": "hello"}]
10913 }),
10914 )
10915 .await;
10916 assert_eq!(response.status(), AxumStatusCode::OK);
10917
10918 let request = engine.last_request();
10919 assert!(request.prompt.ends_with("<|im_start|>assistant\n<think>\n"));
10920 assert!(!request
10921 .metadata
10922 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10923 }
10924
10925 #[tokio::test]
10926 async fn server_thinking_default_applies_but_request_override_wins() {
10927 let template = ModelChatTemplate::new(
10928 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
10929 "test-template",
10930 );
10931 let (router, engine) =
10932 router_with_capturing_llm_and_template_default(template, Some(false));
10933
10934 let response = post_json(
10935 router.clone(),
10936 "/v1/chat/completions",
10937 json!({
10938 "model": "served-alias",
10939 "messages": [{"role": "user", "content": "hello"}]
10940 }),
10941 )
10942 .await;
10943 assert_eq!(response.status(), AxumStatusCode::OK);
10944 let request = engine.last_request();
10945 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
10946 assert_eq!(
10947 request
10948 .metadata
10949 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10950 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
10951 );
10952
10953 let response = post_json(
10954 router,
10955 "/v1/chat/completions",
10956 json!({
10957 "model": "served-alias",
10958 "messages": [{"role": "user", "content": "hello"}],
10959 "chat_template_kwargs": {"enable_thinking": true}
10960 }),
10961 )
10962 .await;
10963 assert_eq!(response.status(), AxumStatusCode::OK);
10964 let request = engine.last_request();
10965 assert_eq!(request.prompt, "<assistant><think>\n");
10966 assert!(!request
10967 .metadata
10968 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10969 }
10970
10971 #[tokio::test]
10972 async fn chat_template_enable_thinking_true_overrides_default() {
10973 let template = ModelChatTemplate::new(
10974 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
10975 "test-template",
10976 );
10977 let (router, engine) = router_with_capturing_llm_and_template(template);
10978 let response = post_json(
10979 router,
10980 "/v1/chat/completions",
10981 json!({
10982 "model": "served-alias",
10983 "messages": [{"role": "user", "content": "hello"}],
10984 "chat_template_kwargs": {"enable_thinking": true}
10985 }),
10986 )
10987 .await;
10988 assert_eq!(response.status(), AxumStatusCode::OK);
10989
10990 let request = engine.last_request();
10991 assert_eq!(request.prompt, "<assistant><think>\n");
10992 assert!(!request
10993 .metadata
10994 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10995 }
10996
10997 #[tokio::test]
10998 async fn chat_template_enable_thinking_false_is_a_hard_override() {
10999 let template = ModelChatTemplate::new(
11000 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% else %}<think>\n{% endif %}{% endif %}",
11001 "test-template",
11002 );
11003 let (router, engine) = router_with_capturing_llm_and_template(template);
11004 let response = post_json(
11005 router,
11006 "/v1/chat/completions",
11007 json!({
11008 "model": "served-alias",
11009 "messages": [{"role": "user", "content": "hello"}],
11010 "chat_template_kwargs": {"enable_thinking": false}
11011 }),
11012 )
11013 .await;
11014 assert_eq!(response.status(), AxumStatusCode::OK);
11015
11016 let request = engine.last_request();
11017 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
11018 assert_eq!(
11019 request
11020 .metadata
11021 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
11022 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
11023 );
11024 }
11025
11026 #[tokio::test]
11027 async fn chat_template_reasoning_effort_is_typed_and_rendered() {
11028 let template = ModelChatTemplate::new(
11029 "{% if reasoning_effort is defined %}Reasoning: {{ reasoning_effort }}{% else %}Reasoning: model-default{% endif %}",
11030 "test-template",
11031 );
11032 let (router, engine) = router_with_capturing_llm_and_template(template);
11033 let response = post_json(
11034 router.clone(),
11035 "/v1/chat/completions",
11036 json!({
11037 "model": "served-alias",
11038 "messages": [{"role": "user", "content": "hello"}],
11039 "chat_template_kwargs": {"reasoning_effort": "low"}
11040 }),
11041 )
11042 .await;
11043 assert_eq!(response.status(), AxumStatusCode::OK);
11044 assert_eq!(engine.last_request().prompt, "Reasoning: low");
11045
11046 let response = post_json(
11047 router.clone(),
11048 "/v1/chat/completions",
11049 json!({
11050 "model": "served-alias",
11051 "messages": [{"role": "user", "content": "hello"}],
11052 "chat_template_kwargs": {"reasoning_effort": "xhigh"}
11053 }),
11054 )
11055 .await;
11056 assert_eq!(response.status(), AxumStatusCode::OK);
11057 assert_eq!(engine.last_request().prompt, "Reasoning: xhigh");
11058
11059 for invalid in [json!("extreme"), json!(1)] {
11060 let response = post_json(
11061 router.clone(),
11062 "/v1/chat/completions",
11063 json!({
11064 "model": "served-alias",
11065 "messages": [{"role": "user", "content": "hello"}],
11066 "chat_template_kwargs": {"reasoning_effort": invalid}
11067 }),
11068 )
11069 .await;
11070 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11071 let body = response_json(response).await;
11072 assert_eq!(body["error"]["type"], "invalid_request_error");
11073 assert!(body["error"]["message"]
11074 .as_str()
11075 .unwrap_or_default()
11076 .contains("reasoning_effort"));
11077 }
11078 }
11079
11080 #[tokio::test]
11081 async fn chat_template_enable_thinking_rejects_non_bool() {
11082 let template = ModelChatTemplate::new(
11083 "{% if add_generation_prompt %}<assistant>{% endif %}",
11084 "test-template",
11085 );
11086 let (router, _) = router_with_capturing_llm_and_template(template);
11087 let response = post_json(
11088 router,
11089 "/v1/chat/completions",
11090 json!({
11091 "model": "served-alias",
11092 "messages": [{"role": "user", "content": "hello"}],
11093 "chat_template_kwargs": {"enable_thinking": "false"}
11094 }),
11095 )
11096 .await;
11097 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11098 let body = response_json(response).await;
11099 assert_eq!(body["error"]["type"], "invalid_request_error");
11100 assert!(body["error"]["message"]
11101 .as_str()
11102 .unwrap_or_default()
11103 .contains("chat_template_kwargs.enable_thinking must be a boolean"));
11104 }
11105
11106 #[tokio::test]
11107 async fn stop_string_strips_chat_and_completion_suffixes() {
11108 let chat = post_json(
11109 router_with_stub("hello<END>"),
11110 "/v1/chat/completions",
11111 json!({
11112 "model": "stub-model",
11113 "messages": [{"role": "user", "content": "hello"}],
11114 "stop": "<END>"
11115 }),
11116 )
11117 .await;
11118 assert_eq!(chat.status(), AxumStatusCode::OK);
11119 let chat_body = response_json(chat).await;
11120 assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
11121
11122 let completion = post_json(
11123 router_with_stub("done<END>"),
11124 "/v1/completions",
11125 json!({
11126 "model": "stub-model",
11127 "prompt": "complete",
11128 "stop": "<END>"
11129 }),
11130 )
11131 .await;
11132 assert_eq!(completion.status(), AxumStatusCode::OK);
11133 let completion_body = response_json(completion).await;
11134 assert_eq!(completion_body["choices"][0]["text"], "done");
11135 }
11136
11137 #[test]
11138 fn started_in_think_parse_streams_reasoning_before_end_tag() {
11139 let parsed = parse_reasoning_response_started_in_think("Okay, the user wants");
11143 assert_eq!(parsed.reasoning.as_deref(), Some("Okay, the user wants"));
11144 assert_eq!(parsed.content, "");
11145
11146 let parsed = parse_reasoning_response_started_in_think("thinking...</think>\nanswer");
11147 assert_eq!(parsed.reasoning.as_deref(), Some("thinking..."));
11148 assert_eq!(parsed.content, "answer");
11149
11150 let parsed = parse_reasoning_response_started_in_think("<think>\nx\n</think>\n\nanswer");
11152 assert_eq!(parsed.reasoning.as_deref(), Some("\nx\n"));
11153 assert_eq!(parsed.content, "answer");
11154 }
11155
11156 #[tokio::test]
11157 async fn chat_response_splits_reasoning_from_content() {
11158 let response = post_json(
11159 router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
11160 "/v1/chat/completions",
11161 json!({
11162 "model": "stub-model",
11163 "messages": [{"role": "user", "content": "hello"}]
11164 }),
11165 )
11166 .await;
11167 assert_eq!(response.status(), AxumStatusCode::OK);
11168
11169 let body = response_json(response).await;
11170 let message = &body["choices"][0]["message"];
11171 assert_eq!(message["content"], "final answer");
11172 assert_eq!(message["reasoning"], "\nreasoning\n");
11173 assert!(message.get("reasoning_content").is_none());
11174 }
11175
11176 #[tokio::test]
11177 async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
11178 let response = post_json(
11179 router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
11180 "/v1/chat/completions",
11181 json!({
11182 "model": "stub-model",
11183 "messages": [{"role": "user", "content": "think then answer"}],
11184 "stream": true
11185 }),
11186 )
11187 .await;
11188 assert_eq!(response.status(), AxumStatusCode::OK);
11189 let body = response_text(response).await;
11190 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11191 assert!(
11192 body.contains(r#""reasoning":"\nreason"#),
11193 "stream should emit reasoning delta after full think prefix: {body}"
11194 );
11195 assert!(!body.contains("\"reasoning_content\":"));
11196 assert!(
11197 body.contains(r#""content":"final""#),
11198 "stream should emit visible content after think close: {body}"
11199 );
11200 assert!(
11201 !body.contains(r#""content":"<"#),
11202 "partial think prefix must not leak as content: {body}"
11203 );
11204 }
11205
11206 #[tokio::test]
11207 async fn route_rejects_unsupported_tool_and_function_selection() {
11208 for (extra, param) in [
11209 (
11210 json!({
11211 "tools": [{
11212 "type": "function",
11213 "function": {"name": "weather", "parameters": {"type": "object"}}
11214 }],
11215 "tool_choice": {
11216 "type": "function",
11217 "function": {"name": "calendar"}
11218 }
11219 }),
11220 "tool_choice",
11221 ),
11222 (
11223 json!({
11224 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
11225 "function_call": {"name": "calendar"}
11226 }),
11227 "function_call",
11228 ),
11229 ] {
11230 let mut body = json!({
11231 "model": "stub-model",
11232 "messages": [{"role": "user", "content": "hello"}]
11233 });
11234 body.as_object_mut()
11235 .expect("object")
11236 .extend(extra.as_object().expect("extra object").clone());
11237 let response =
11238 post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
11239 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11240 let body = response_json(response).await;
11241 assert_eq!(body["error"]["type"], "invalid_request_error");
11242 assert_eq!(body["error"]["param"], param);
11243 }
11244 }
11245
11246 #[tokio::test]
11247 async fn route_rejects_non_function_tools_with_openai_error_param() {
11248 let response = post_json(
11249 router_with_stub("unused"),
11250 "/v1/chat/completions",
11251 json!({
11252 "model": "stub-model",
11253 "messages": [{"role": "user", "content": "hello"}],
11254 "tools": [{
11255 "type": "retrieval",
11256 "function": {"name": "search", "parameters": {"type": "object"}}
11257 }]
11258 }),
11259 )
11260 .await;
11261 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11262 let body = response_json(response).await;
11263 assert_eq!(body["error"]["type"], "invalid_request_error");
11264 assert_eq!(body["error"]["param"], "tools");
11265 }
11266
11267 #[tokio::test]
11268 async fn route_rejects_tool_choice_required_without_tools() {
11269 let response = post_json(
11270 router_with_stub("unused"),
11271 "/v1/chat/completions",
11272 json!({
11273 "model": "stub-model",
11274 "messages": [{"role": "user", "content": "hello"}],
11275 "tool_choice": "required"
11276 }),
11277 )
11278 .await;
11279 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11280 let body = response_json(response).await;
11281 assert_eq!(body["error"]["type"], "invalid_request_error");
11282 assert_eq!(body["error"]["param"], "tool_choice");
11283 }
11284
11285 #[tokio::test]
11286 async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
11287 let response = post_json(
11288 router_with_stub("unused"),
11289 "/v1/chat/completions",
11290 json!({
11291 "model": "stub-model",
11292 "messages": [{"role": "user", "content": "hello"}],
11293 "response_format": {"type": "xml"}
11294 }),
11295 )
11296 .await;
11297 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11298 let body = response_json(response).await;
11299 assert_eq!(body["error"]["type"], "invalid_request_error");
11300 assert_eq!(body["error"]["param"], "response_format.type");
11301 }
11302
11303 #[tokio::test]
11304 async fn route_chat_engine_unavailable_maps_to_503() {
11305 let response = post_json(
11306 router_without_llm(),
11307 "/v1/chat/completions",
11308 json!({
11309 "model": "stub-model",
11310 "messages": [{"role": "user", "content": "hello"}]
11311 }),
11312 )
11313 .await;
11314 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11315 let body = response_json(response).await;
11316 assert_eq!(body["error"]["type"], "service_unavailable_error");
11317 assert_eq!(body["error"]["param"], Value::Null);
11318 }
11319
11320 #[tokio::test]
11321 async fn context_capacity_rejection_has_a_structured_code_on_openai_routes() {
11322 for stream in [false, true] {
11323 for (path, mut input) in [
11324 (
11325 "/v1/chat/completions",
11326 json!({"messages":[{"role":"user","content":"hello"}],"max_tokens":100}),
11327 ),
11328 (
11329 "/v1/completions",
11330 json!({"prompt":"hello","max_tokens":100}),
11331 ),
11332 (
11333 "/v1/responses",
11334 json!({"input":"hello","max_output_tokens":100}),
11335 ),
11336 ] {
11337 input["model"] = json!("failing-model");
11338 input["stream"] = json!(stream);
11339 let router = AxumServer::from_llm(Arc::new(FailingLlm::context_length_exceeded()))
11340 .build_router();
11341 let response = post_json(router, path, input).await;
11342 assert_eq!(
11343 response.status(),
11344 AxumStatusCode::BAD_REQUEST,
11345 "{path}, stream={stream}"
11346 );
11347 let body = response_json(response).await;
11348 assert_eq!(body["error"]["code"], "context_length_exceeded", "{body}");
11349 assert_eq!(body["error"]["type"], "invalid_request_error");
11350 assert!(body["error"]["message"]
11351 .as_str()
11352 .unwrap()
11353 .contains("500 input tokens + 100 output tokens"));
11354 }
11355 }
11356 let ordinary =
11357 server_error_from_ferrum_error(Error::request_validation("invalid parameter"))
11358 .into_response();
11359 assert_eq!(response_json(ordinary).await["error"]["code"], Value::Null);
11360 }
11361
11362 #[tokio::test]
11363 async fn route_chat_generation_failure_maps_to_500() {
11364 let response = post_json(
11365 router_with_failing_llm(),
11366 "/v1/chat/completions",
11367 json!({
11368 "model": "failing-model",
11369 "messages": [{"role": "user", "content": "hello"}]
11370 }),
11371 )
11372 .await;
11373 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11374 let body = response_json(response).await;
11375 assert_eq!(body["error"]["type"], "internal_server_error");
11376 assert!(body["error"]["message"]
11377 .as_str()
11378 .unwrap()
11379 .contains("stub generation failed"));
11380 }
11381
11382 #[tokio::test]
11383 async fn route_chat_generation_failure_writes_replay_diagnostics() {
11384 let root = unique_request_dump_dir("chat-sync-failure");
11385 let profile = unique_profile_jsonl("chat-sync-failure");
11386 let response = post_json(
11387 router_with_failing_llm_request_dump_and_profile(root.clone(), profile.clone()),
11388 "/v1/chat/completions",
11389 json!({
11390 "model": "failing-model",
11391 "messages": [{"role": "user", "content": "hello"}]
11392 }),
11393 )
11394 .await;
11395 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11396 assert_chat_failure_replay_bundle(
11397 &root,
11398 "chat_completions_sync",
11399 "internal",
11400 "stub generation failed",
11401 );
11402 let event = read_profile_events(&profile)
11403 .into_iter()
11404 .find(|event| event["phase"] == "chat_completions_sync")
11405 .expect("sync failure profile event");
11406 assert_eq!(event["event_kind"], "timed_span");
11407 assert_eq!(event["status"], "failure");
11408 assert!(event["duration_us"].as_u64().is_some());
11409 assert_eq!(event["attributes"]["terminal_failure_event"], true);
11410 assert_eq!(event["error"]["kind"], "internal");
11411 let _ = fs::remove_dir_all(root);
11412 let _ = fs::remove_file(profile);
11413 }
11414
11415 #[tokio::test]
11416 async fn route_chat_resource_failure_writes_resource_replay_diagnostics() {
11417 let root = unique_request_dump_dir("chat-resource-failure");
11418 let response = post_json(
11419 router_with_resource_exhausted_llm_and_request_dump_dir(root.clone()),
11420 "/v1/chat/completions",
11421 json!({
11422 "model": "failing-model",
11423 "messages": [{"role": "user", "content": "hello"}]
11424 }),
11425 )
11426 .await;
11427 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11428 let bundle = only_replay_bundle(&root);
11429 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11430 assert_eq!(bad_scan["failure_kind"], "oom_admission");
11431 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
11432 assert_eq!(diagnostics["failure_kind"], "oom_admission");
11433 assert_eq!(
11434 diagnostics["first_failure_event"]["error_kind"],
11435 "resource_exhausted"
11436 );
11437 assert_eq!(
11438 diagnostics["capacity"]["resource_kind"],
11439 "admission_capacity"
11440 );
11441 assert!(diagnostics["capacity"]["reason"]
11442 .as_str()
11443 .expect("capacity reason")
11444 .contains("admission capacity exhausted"));
11445 assert_eq!(
11446 diagnostics["nearest_resource_event"]["resource_kind"],
11447 "admission_capacity"
11448 );
11449 assert!(diagnostics["nearest_memory_snapshot"]["current_bytes"].is_number());
11450 assert!(diagnostics["nearest_memory_snapshot"]["high_water_bytes"].is_number());
11451 let _ = fs::remove_dir_all(root);
11452 }
11453
11454 #[tokio::test]
11455 async fn route_chat_sync_success_updates_replay_output_tokens() {
11456 let root = unique_request_dump_dir("chat-sync-success-output");
11457 let response = post_json(
11458 router_with_stub_and_request_dump_dir("OK", root.clone()),
11459 "/v1/chat/completions",
11460 json!({
11461 "model": "stub-model",
11462 "messages": [{"role": "user", "content": "hello"}]
11463 }),
11464 )
11465 .await;
11466 assert_eq!(response.status(), AxumStatusCode::OK);
11467 let body = response_json(response).await;
11468 assert_eq!(body["choices"][0]["message"]["content"], "OK");
11469 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11470 let _ = fs::remove_dir_all(root);
11471 }
11472
11473 #[tokio::test]
11474 async fn route_chat_sync_success_writes_product_profile_event() {
11475 let root = unique_request_dump_dir("chat-sync-profile");
11476 let profile = unique_profile_jsonl("chat-sync-profile");
11477 let response = post_json(
11478 router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11479 "/v1/chat/completions",
11480 json!({
11481 "model": "stub-model",
11482 "messages": [{"role": "user", "content": "hello"}]
11483 }),
11484 )
11485 .await;
11486 assert_eq!(response.status(), AxumStatusCode::OK);
11487 let response_body = response_json(response).await;
11488
11489 let events = read_profile_events(&profile);
11490 assert_eq!(events.len(), 2, "events: {events:#?}");
11491 let event = events
11492 .iter()
11493 .find(|event| event["phase"] == "chat_completions_sync_complete")
11494 .expect("sync completion profile event");
11495 assert!(response_body["id"]
11496 .as_str()
11497 .is_some_and(|id| !id.is_empty()));
11498 assert_eq!(response_body["id"], event["request_id"]);
11499 assert_eq!(response_body["id"], event["correlation_id"]);
11500 assert_eq!(
11501 event["schema_version"],
11502 OBSERVABILITY_PROFILE_SCHEMA_VERSION
11503 );
11504 assert_eq!(event["entrypoint"], "serve");
11505 assert_eq!(event["event_kind"], "timed_span");
11506 assert_eq!(event["status"], "ok");
11507 assert_eq!(event["phase"], "chat_completions_sync_complete");
11508 assert_eq!(event["attributes"]["actual_model_smoke"], true);
11509 assert_eq!(event["attributes"]["profile_detail"], "latency");
11510 assert_eq!(event["attributes"]["diagnostic_only"], false);
11511 assert_eq!(event["attributes"]["stream"], false);
11512 assert_eq!(event["attributes"]["output_token_count"], 2);
11513 assert_eq!(event["attributes"]["prompt_token_count"], 7);
11514 assert_eq!(event["attributes"]["completion_token_count"], 2);
11515 assert_eq!(event["attributes"]["total_token_count"], 9);
11516 assert_eq!(event["attributes"]["token_count_source"], "usage");
11517 assert_eq!(event["attributes"]["finish_reason"], "stop");
11518 assert_eq!(
11519 event["attributes"]["engine_token_clock_source"],
11520 "rust_std_instant"
11521 );
11522 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11523 assert_eq!(event["attributes"]["itl_interval_count"], 1);
11524 assert_eq!(event["attributes"]["ttft_us"], 1_000);
11525 assert_eq!(event["attributes"]["itl_us_avg"], 1_000);
11526 assert!(event["attributes"]["http_first_sse_enqueue_us"].is_null());
11527 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11528 assert!(
11529 event["attributes"]["e2e_duration_us"]
11530 .as_u64()
11531 .unwrap_or_default()
11532 > 0
11533 );
11534 assert_eq!(
11535 event["replay"]["bundle_dir"].as_str(),
11536 Some(root.to_string_lossy().as_ref())
11537 );
11538 assert!(event["replay"]["command"]
11539 .as_str()
11540 .unwrap_or_default()
11541 .contains("replay_body.json"));
11542 let memory_event = events
11543 .iter()
11544 .find(|event| event["phase"] == "actual_serve_first_request_done")
11545 .expect("first request memory profile event");
11546 assert_eq!(memory_event["event_kind"], "memory");
11547 assert_eq!(
11548 memory_event["attributes"]["memory_stage"],
11549 "first_request_done"
11550 );
11551 assert_eq!(
11552 memory_event["attributes"]["memory_measurement"],
11553 "process_rss"
11554 );
11555 assert!(memory_event["memory"]["current_bytes"]
11556 .as_u64()
11557 .is_some_and(|bytes| bytes > 0));
11558 let _ = fs::remove_dir_all(root);
11559 let _ = fs::remove_file(profile);
11560 }
11561
11562 #[tokio::test]
11563 async fn route_chat_profile_events_preserve_benchmark_correlation() {
11564 let root = unique_request_dump_dir("chat-benchmark-correlation");
11565 let profile = unique_profile_jsonl("chat-benchmark-correlation");
11566 let correlation = BenchmarkRequestCorrelation::new(
11567 "bench-123".to_string(),
11568 "cell-1-closed-c8".to_string(),
11569 2,
11570 ferrum_bench_core::BenchmarkPhase::Measured,
11571 17,
11572 )
11573 .unwrap();
11574 let response = post_json_with_benchmark_correlation(
11575 router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11576 "/v1/chat/completions",
11577 json!({
11578 "model": "stub-model",
11579 "messages": [{"role": "user", "content": "hello"}]
11580 }),
11581 &correlation,
11582 )
11583 .await;
11584 assert_eq!(response.status(), AxumStatusCode::OK);
11585 let _ = response_json(response).await;
11586
11587 let events = read_profile_events(&profile);
11588 assert_eq!(events.len(), 2, "events: {events:#?}");
11589 for event in events {
11590 assert_eq!(event["attributes"]["benchmark_run_id"], "bench-123");
11591 assert_eq!(event["attributes"]["cell_id"], "cell-1-closed-c8");
11592 assert_eq!(event["attributes"]["repeat_index"], 2);
11593 assert_eq!(event["attributes"]["phase"], "measured");
11594 assert_eq!(event["attributes"]["request_index"], 17);
11595 }
11596 let _ = fs::remove_dir_all(root);
11597 let _ = fs::remove_file(profile);
11598 }
11599
11600 #[tokio::test]
11601 async fn route_chat_rejects_partial_benchmark_correlation_headers() {
11602 let response = router_with_stub("OK")
11603 .oneshot(
11604 Request::builder()
11605 .method("POST")
11606 .uri("/v1/chat/completions")
11607 .header(header::CONTENT_TYPE, "application/json")
11608 .header(BENCHMARK_RUN_ID_HEADER, "bench-123")
11609 .body(Body::from(
11610 json!({
11611 "model": "stub-model",
11612 "messages": [{"role": "user", "content": "hello"}]
11613 })
11614 .to_string(),
11615 ))
11616 .expect("request"),
11617 )
11618 .await
11619 .expect("route response");
11620 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11621 }
11622
11623 #[tokio::test]
11624 async fn route_chat_sync_profile_jsonl_is_parseable_under_concurrent_requests() {
11625 let root = unique_request_dump_dir("chat-sync-profile-concurrent");
11626 let profile = unique_profile_jsonl("chat-sync-profile-concurrent");
11627 let app = router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone());
11628
11629 let mut handles = Vec::new();
11630 for request_index in 0..8 {
11631 let app = app.clone();
11632 handles.push(tokio::spawn(async move {
11633 let response = post_json(
11634 app,
11635 "/v1/chat/completions",
11636 json!({
11637 "model": "stub-model",
11638 "messages": [{"role": "user", "content": format!("hello {request_index}")}]
11639 }),
11640 )
11641 .await;
11642 assert_eq!(response.status(), AxumStatusCode::OK);
11643 let body = response_json(response).await;
11644 assert_eq!(body["choices"][0]["message"]["content"], "OK");
11645 }));
11646 }
11647
11648 for handle in handles {
11649 handle.await.expect("concurrent request task");
11650 }
11651
11652 let raw = fs::read_to_string(&profile).expect("profile jsonl");
11653 let mut completion_events = 0usize;
11654 for (line_index, line) in raw
11655 .lines()
11656 .filter(|line| !line.trim().is_empty())
11657 .enumerate()
11658 {
11659 let event: Value = serde_json::from_str(line).unwrap_or_else(|err| {
11660 panic!(
11661 "profile line {} invalid JSON: {err}: {line}",
11662 line_index + 1
11663 )
11664 });
11665 if event["phase"] == "chat_completions_sync_complete" {
11666 completion_events += 1;
11667 }
11668 }
11669 assert_eq!(completion_events, 8);
11670 let _ = fs::remove_dir_all(root);
11671 let _ = fs::remove_file(profile);
11672 }
11673
11674 #[tokio::test]
11675 async fn route_chat_stream_success_updates_replay_output_tokens() {
11676 let root = unique_request_dump_dir("chat-stream-success-output");
11677 let response = post_json(
11678 router_with_stub_stream_chunks_and_request_dump_dir(&["O", "K"], root.clone()),
11679 "/v1/chat/completions",
11680 json!({
11681 "model": "stub-model",
11682 "messages": [{"role": "user", "content": "hello"}],
11683 "stream": true
11684 }),
11685 )
11686 .await;
11687 assert_eq!(response.status(), AxumStatusCode::OK);
11688 let body = response_text(response).await;
11689 assert!(body.contains("data: [DONE]"), "body: {body}");
11690 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11691 let _ = fs::remove_dir_all(root);
11692 }
11693
11694 #[tokio::test]
11695 async fn route_chat_stream_success_writes_product_profile_event() {
11696 let root = unique_request_dump_dir("chat-stream-profile");
11697 let profile = unique_profile_jsonl("chat-stream-profile");
11698 let response = post_json(
11699 router_with_stub_stream_request_dump_and_profile(
11700 &["O", "K"],
11701 root.clone(),
11702 profile.clone(),
11703 ),
11704 "/v1/chat/completions",
11705 json!({
11706 "model": "stub-model",
11707 "messages": [{"role": "user", "content": "hello"}],
11708 "stream": true
11709 }),
11710 )
11711 .await;
11712 assert_eq!(response.status(), AxumStatusCode::OK);
11713 let body = response_text(response).await;
11714 assert!(body.contains("data: [DONE]"), "body: {body}");
11715
11716 let events = read_profile_events(&profile);
11717 assert_eq!(events.len(), 2, "events: {events:#?}");
11718 let event = events
11719 .iter()
11720 .find(|event| event["phase"] == "chat_completions_stream_complete")
11721 .expect("stream completion profile event");
11722 let chunks = responses_sse_json_events(&body);
11723 assert!(!chunks.is_empty());
11724 for chunk in chunks {
11725 assert!(chunk["id"].as_str().is_some_and(|id| !id.is_empty()));
11726 assert_eq!(chunk["id"], event["request_id"]);
11727 assert_eq!(chunk["id"], event["correlation_id"]);
11728 }
11729 assert_eq!(
11730 event["schema_version"],
11731 OBSERVABILITY_PROFILE_SCHEMA_VERSION
11732 );
11733 assert_eq!(event["entrypoint"], "serve");
11734 assert_eq!(event["event_kind"], "timed_span");
11735 assert_eq!(event["status"], "ok");
11736 assert_eq!(event["phase"], "chat_completions_stream_complete");
11737 assert_eq!(event["attributes"]["actual_model_smoke"], true);
11738 assert_eq!(event["attributes"]["profile_detail"], "latency");
11739 assert_eq!(event["attributes"]["diagnostic_only"], false);
11740 assert_eq!(event["attributes"]["stream"], true);
11741 assert_eq!(event["attributes"]["output_token_count"], 2);
11742 assert_eq!(event["attributes"]["prompt_token_count"], 5);
11743 assert_eq!(event["attributes"]["completion_token_count"], 2);
11744 assert_eq!(event["attributes"]["total_token_count"], 7);
11745 assert_eq!(event["attributes"]["token_count_source"], "usage");
11746 assert_eq!(event["attributes"]["finish_reason"], "stop");
11747 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11748 assert!(
11749 event["attributes"]["e2e_duration_us"]
11750 .as_u64()
11751 .unwrap_or_default()
11752 > 0
11753 );
11754 assert!(event["attributes"]["ttft_us"].as_u64().is_some());
11755 assert!(event["attributes"]["itl_us_avg"].as_u64().is_some());
11756 assert_eq!(
11757 event["attributes"]["engine_token_commit_nanos_since_request_start"],
11758 json!([1_000_000, 2_000_000])
11759 );
11760 assert_eq!(event["attributes"]["itl_interval_count"], 1);
11761 assert_eq!(event["attributes"]["itl_source"], "engine_token_commit");
11762 assert!(event["attributes"]["engine_stream_first_chunk_received_us"]
11763 .as_u64()
11764 .is_some());
11765 assert!(event["attributes"]["http_first_sse_enqueue_us"]
11766 .as_u64()
11767 .is_some());
11768 assert!(event["attributes"]["http_stream_flush_unavailable_reason"]
11769 .as_str()
11770 .is_some());
11771 assert_eq!(
11772 event["replay"]["bundle_dir"].as_str(),
11773 Some(root.to_string_lossy().as_ref())
11774 );
11775 let memory_event = events
11776 .iter()
11777 .find(|event| event["phase"] == "actual_serve_first_request_done")
11778 .expect("first request memory profile event");
11779 assert_eq!(memory_event["event_kind"], "memory");
11780 assert_eq!(
11781 memory_event["attributes"]["memory_stage"],
11782 "first_request_done"
11783 );
11784 assert_eq!(
11785 memory_event["attributes"]["memory_measurement"],
11786 "process_rss"
11787 );
11788 assert!(memory_event["memory"]["current_bytes"]
11789 .as_u64()
11790 .is_some_and(|bytes| bytes > 0));
11791 let _ = fs::remove_dir_all(root);
11792 let _ = fs::remove_file(profile);
11793 }
11794
11795 #[tokio::test]
11796 async fn route_chat_stream_profile_retains_non_visible_terminal_token() {
11797 let root = unique_request_dump_dir("chat-stream-profile-terminal-token");
11798 let profile = unique_profile_jsonl("chat-stream-profile-terminal-token");
11799 let llm = StubLlm {
11800 stream_usage: Some(TokenUsage::new(5, 2)),
11801 ..StubLlm::with_stream_chunks(&["Paris"])
11802 };
11803 let app = AxumServer::from_state(
11804 AppState::default()
11805 .with_llm(Arc::new(llm))
11806 .with_request_dump_dir(Some(root.clone()))
11807 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
11808 .with_profile_jsonl(Some(profile.clone())),
11809 )
11810 .build_router();
11811
11812 let response = post_json(
11813 app,
11814 "/v1/chat/completions",
11815 json!({
11816 "model": "stub-model",
11817 "messages": [{"role": "user", "content": "hello"}],
11818 "stream": true,
11819 "stream_options": {"include_usage": true}
11820 }),
11821 )
11822 .await;
11823 assert_eq!(response.status(), AxumStatusCode::OK);
11824 let body = response_text(response).await;
11825 assert!(body.contains("\"completion_tokens\":2"), "body: {body}");
11826 assert!(body.contains("data: [DONE]"), "body: {body}");
11827
11828 let events = read_profile_events(&profile);
11829 let event = events
11830 .iter()
11831 .find(|event| event["phase"] == "chat_completions_stream_complete")
11832 .expect("stream completion profile event");
11833 assert_eq!(event["attributes"]["output_token_count"], 2);
11834 assert_eq!(event["attributes"]["completion_token_count"], 2);
11835 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11836 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "Paris");
11837
11838 let _ = fs::remove_dir_all(root);
11839 let _ = fs::remove_file(profile);
11840 }
11841
11842 #[tokio::test]
11843 async fn route_chat_sync_bad_output_updates_replay_classifier() {
11844 let root = unique_request_dump_dir("chat-sync-bad-output");
11845 let response = post_json(
11846 router_with_stub_and_request_dump_dir("<unk>", root.clone()),
11847 "/v1/chat/completions",
11848 json!({
11849 "model": "stub-model",
11850 "messages": [{"role": "user", "content": "hello"}]
11851 }),
11852 )
11853 .await;
11854 assert_eq!(response.status(), AxumStatusCode::OK);
11855 let _ = response_json(response).await;
11856 let bundle = only_replay_bundle(&root);
11857 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11858 assert_eq!(bad_scan["bad_output"], true);
11859 assert_eq!(bad_scan["reasons"], json!(["reserved_token"]));
11860 assert_eq!(bad_scan["first_bad_text_span"]["reason"], "reserved_token");
11861 let _ = fs::remove_dir_all(root);
11862 }
11863
11864 #[tokio::test]
11865 async fn route_chat_stream_generation_failure_emits_openai_error_event() {
11866 let response = post_json(
11867 router_with_failing_llm(),
11868 "/v1/chat/completions",
11869 json!({
11870 "model": "failing-model",
11871 "messages": [{"role": "user", "content": "hello"}],
11872 "stream": true
11873 }),
11874 )
11875 .await;
11876 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11877 let body = response_json(response).await;
11878 assert_eq!(body["error"]["type"], "internal_server_error");
11879 assert!(body["error"]["message"]
11880 .as_str()
11881 .unwrap_or_default()
11882 .contains("stub stream failed"));
11883 }
11884
11885 #[tokio::test]
11886 async fn route_chat_stream_generation_failure_writes_replay_diagnostics() {
11887 let root = unique_request_dump_dir("chat-stream-start-failure");
11888 let response = post_json(
11889 router_with_failing_llm_and_request_dump_dir(root.clone()),
11890 "/v1/chat/completions",
11891 json!({
11892 "model": "failing-model",
11893 "messages": [{"role": "user", "content": "hello"}],
11894 "stream": true
11895 }),
11896 )
11897 .await;
11898 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11899 let body = response_json(response).await;
11900 assert_eq!(body["error"]["type"], "internal_server_error");
11901 assert!(body["error"]["message"]
11902 .as_str()
11903 .unwrap_or_default()
11904 .contains("stub stream failed"));
11905 assert_chat_failure_replay_bundle(
11906 &root,
11907 "chat_completions_stream_start",
11908 "internal",
11909 "stub stream failed",
11910 );
11911 let _ = fs::remove_dir_all(root);
11912 }
11913
11914 #[tokio::test]
11915 async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
11916 let response = post_json(
11917 router_with_stream_chunk_failing_llm(),
11918 "/v1/chat/completions",
11919 json!({
11920 "model": "failing-model",
11921 "messages": [{"role": "user", "content": "hello"}],
11922 "stream": true
11923 }),
11924 )
11925 .await;
11926 assert_eq!(response.status(), AxumStatusCode::OK);
11927 let body = response_text(response).await;
11928 assert_openai_stream_error(&body, "stub stream chunk failed");
11929 }
11930
11931 #[tokio::test]
11932 async fn route_chat_stream_chunk_failure_writes_replay_diagnostics() {
11933 let root = unique_request_dump_dir("chat-stream-chunk-failure");
11934 let response = post_json(
11935 router_with_stream_chunk_failing_llm_and_request_dump_dir(root.clone()),
11936 "/v1/chat/completions",
11937 json!({
11938 "model": "failing-model",
11939 "messages": [{"role": "user", "content": "hello"}],
11940 "stream": true
11941 }),
11942 )
11943 .await;
11944 assert_eq!(response.status(), AxumStatusCode::OK);
11945 let body = response_text(response).await;
11946 assert_openai_stream_error(&body, "stub stream chunk failed");
11947 assert_chat_failure_replay_bundle(
11948 &root,
11949 "chat_completions_stream_next",
11950 "internal",
11951 "stub stream chunk failed",
11952 );
11953 let _ = fs::remove_dir_all(root);
11954 }
11955
11956 #[tokio::test]
11957 async fn route_completions_engine_unavailable_maps_to_503() {
11958 let response = post_json(
11959 router_without_llm(),
11960 "/v1/completions",
11961 json!({
11962 "model": "stub-model",
11963 "prompt": "complete me"
11964 }),
11965 )
11966 .await;
11967 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11968 let body = response_json(response).await;
11969 assert_eq!(body["error"]["type"], "service_unavailable_error");
11970 assert_eq!(body["error"]["param"], Value::Null);
11971 }
11972
11973 #[tokio::test]
11974 async fn route_embeddings_engine_unavailable_maps_to_503() {
11975 let response = post_json(
11976 router_without_llm(),
11977 "/v1/embeddings",
11978 json!({
11979 "model": "embed-model",
11980 "input": "hello"
11981 }),
11982 )
11983 .await;
11984 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11985 let body = response_json(response).await;
11986 assert_eq!(body["error"]["type"], "service_unavailable_error");
11987 assert_eq!(body["error"]["param"], Value::Null);
11988 }
11989
11990 #[tokio::test]
11991 async fn route_embeddings_contract_uses_stub_engine() {
11992 let response = post_json(
11993 router_with_stub_embed(),
11994 "/v1/embeddings",
11995 json!({
11996 "model": "stub-embed",
11997 "input": ["hi", "world"],
11998 "encoding_format": "float"
11999 }),
12000 )
12001 .await;
12002 assert_eq!(response.status(), AxumStatusCode::OK);
12003 let body = response_json(response).await;
12004 assert_eq!(body["object"], "list");
12005 assert_eq!(body["model"], "stub-embed");
12006 assert_eq!(body["usage"]["prompt_tokens"], 7);
12007 assert_eq!(body["usage"]["total_tokens"], 7);
12008
12009 let data = body["data"].as_array().expect("embedding data");
12010 assert_eq!(data.len(), 2, "body: {body}");
12011 assert_eq!(data[0]["object"], "embedding");
12012 assert_eq!(data[0]["index"], 0);
12013 assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
12014 assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
12015 assert_eq!(data[1]["index"], 1);
12016 assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
12017 }
12018
12019 #[tokio::test]
12020 async fn route_embeddings_public_alias_succeeds_and_unknown_alias_is_rejected() {
12021 let registry = ServedModelRegistry::try_new(
12022 "stub-embed",
12023 ServedModelKind::Embedding,
12024 vec!["public-embed".to_string()],
12025 vec![],
12026 )
12027 .unwrap();
12028 let server =
12029 AxumServer::from_embed(Arc::new(StubEmbed::new())).with_served_model_registry(registry);
12030 let accepted = post_json(
12031 server.build_router(),
12032 "/v1/embeddings",
12033 json!({"model": "public-embed", "input": "hello"}),
12034 )
12035 .await;
12036 assert_eq!(accepted.status(), AxumStatusCode::OK);
12037 assert_eq!(response_json(accepted).await["model"], "public-embed");
12038
12039 let rejected = post_json(
12040 server.build_router(),
12041 "/v1/embeddings",
12042 json!({"model": "stub-embed", "input": "hello"}),
12043 )
12044 .await;
12045 assert_eq!(rejected.status(), AxumStatusCode::BAD_REQUEST);
12046 let body = response_json(rejected).await;
12047 assert_eq!(body["error"]["type"], "invalid_request_error");
12048 assert_eq!(body["error"]["param"], "model");
12049 }
12050
12051 #[tokio::test]
12052 async fn route_embeddings_rejects_unsupported_encoding_format() {
12053 let response = post_json(
12054 router_with_stub_embed(),
12055 "/v1/embeddings",
12056 json!({
12057 "model": "stub-embed",
12058 "input": "hi",
12059 "encoding_format": "base64"
12060 }),
12061 )
12062 .await;
12063 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12064 let body = response_json(response).await;
12065 assert_eq!(body["error"]["type"], "invalid_request_error");
12066 assert_eq!(body["error"]["param"], "encoding_format");
12067 }
12068
12069 #[tokio::test]
12070 async fn route_embeddings_rejects_empty_input_with_field_param() {
12071 let response = post_json(
12072 router_with_stub_embed(),
12073 "/v1/embeddings",
12074 json!({
12075 "model": "stub-embed",
12076 "input": []
12077 }),
12078 )
12079 .await;
12080 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12081 let body = response_json(response).await;
12082 assert_eq!(body["error"]["type"], "invalid_request_error");
12083 assert_eq!(body["error"]["param"], "input");
12084 }
12085
12086 #[tokio::test]
12087 async fn route_embeddings_rejects_empty_item_with_field_param() {
12088 let response = post_json(
12089 router_with_stub_embed(),
12090 "/v1/embeddings",
12091 json!({
12092 "model": "stub-embed",
12093 "input": [{}]
12094 }),
12095 )
12096 .await;
12097 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12098 let body = response_json(response).await;
12099 assert_eq!(body["error"]["type"], "invalid_request_error");
12100 assert_eq!(body["error"]["param"], "input");
12101 }
12102
12103 #[tokio::test]
12104 async fn route_embeddings_invalid_json_maps_to_openai_error() {
12105 let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
12106 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12107 let body = response_json(response).await;
12108 assert_eq!(body["error"]["type"], "invalid_request_error");
12109 assert_eq!(body["error"]["param"], Value::Null);
12110 assert!(body["error"]["message"]
12111 .as_str()
12112 .unwrap()
12113 .contains("invalid embeddings request"));
12114 }
12115
12116 #[tokio::test]
12117 async fn route_transcriptions_engine_unavailable_maps_to_503() {
12118 let boundary = "ferrum-test-boundary";
12119 let body = concat!(
12120 "--ferrum-test-boundary\r\n",
12121 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12122 "Content-Type: audio/wav\r\n",
12123 "\r\n",
12124 "RIFFtest\r\n",
12125 "--ferrum-test-boundary--\r\n"
12126 );
12127 let response = post_multipart(
12128 router_without_llm(),
12129 "/v1/audio/transcriptions",
12130 boundary,
12131 body,
12132 )
12133 .await;
12134 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
12135 let body = response_json(response).await;
12136 assert_eq!(body["error"]["type"], "service_unavailable_error");
12137 assert_eq!(body["error"]["param"], Value::Null);
12138 }
12139
12140 #[tokio::test]
12141 async fn route_transcriptions_contract_uses_stub_engine() {
12142 let boundary = "ferrum-test-boundary";
12143 let body = concat!(
12144 "--ferrum-test-boundary\r\n",
12145 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12146 "Content-Type: audio/wav\r\n",
12147 "\r\n",
12148 "RIFFtest\r\n",
12149 "--ferrum-test-boundary\r\n",
12150 "Content-Disposition: form-data; name=\"language\"\r\n",
12151 "\r\n",
12152 "en\r\n",
12153 "--ferrum-test-boundary\r\n",
12154 "Content-Disposition: form-data; name=\"response_format\"\r\n",
12155 "\r\n",
12156 "json\r\n",
12157 "--ferrum-test-boundary--\r\n"
12158 );
12159 let response = post_multipart(
12160 router_with_stub_transcribe(),
12161 "/v1/audio/transcriptions",
12162 boundary,
12163 body,
12164 )
12165 .await;
12166 assert_eq!(response.status(), AxumStatusCode::OK);
12167 let body = response_json(response).await;
12168 assert_eq!(body["text"], "bytes:8:en");
12169 }
12170
12171 #[tokio::test]
12172 async fn route_transcriptions_rejects_unsupported_response_format() {
12173 let boundary = "ferrum-test-boundary";
12174 let body = concat!(
12175 "--ferrum-test-boundary\r\n",
12176 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12177 "Content-Type: audio/wav\r\n",
12178 "\r\n",
12179 "RIFFtest\r\n",
12180 "--ferrum-test-boundary\r\n",
12181 "Content-Disposition: form-data; name=\"response_format\"\r\n",
12182 "\r\n",
12183 "text\r\n",
12184 "--ferrum-test-boundary--\r\n"
12185 );
12186 let response = post_multipart(
12187 router_with_stub_transcribe(),
12188 "/v1/audio/transcriptions",
12189 boundary,
12190 body,
12191 )
12192 .await;
12193 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12194 let body = response_json(response).await;
12195 assert_eq!(body["error"]["type"], "invalid_request_error");
12196 assert_eq!(body["error"]["param"], "response_format");
12197 }
12198
12199 #[tokio::test]
12200 async fn route_transcriptions_rejects_missing_file_with_field_param() {
12201 let boundary = "ferrum-test-boundary";
12202 let body = concat!(
12203 "--ferrum-test-boundary\r\n",
12204 "Content-Disposition: form-data; name=\"language\"\r\n",
12205 "\r\n",
12206 "en\r\n",
12207 "--ferrum-test-boundary--\r\n"
12208 );
12209 let response = post_multipart(
12210 router_with_stub_transcribe(),
12211 "/v1/audio/transcriptions",
12212 boundary,
12213 body,
12214 )
12215 .await;
12216 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12217 let body = response_json(response).await;
12218 assert_eq!(body["error"]["type"], "invalid_request_error");
12219 assert_eq!(body["error"]["param"], "file");
12220 }
12221
12222 #[tokio::test]
12223 async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
12224 let response = post_json(
12225 router_with_stub_transcribe(),
12226 "/v1/audio/transcriptions",
12227 json!({}),
12228 )
12229 .await;
12230 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12231 let body = response_json(response).await;
12232 assert_eq!(body["error"]["type"], "invalid_request_error");
12233 assert_eq!(body["error"]["param"], Value::Null);
12234 assert!(body["error"]["message"]
12235 .as_str()
12236 .unwrap()
12237 .contains("invalid transcriptions request"));
12238 }
12239
12240 #[tokio::test]
12241 async fn route_speech_engine_unavailable_maps_to_503() {
12242 let response = post_json(
12243 router_without_llm(),
12244 "/v1/audio/speech",
12245 json!({
12246 "model": "tts-model",
12247 "input": "hello",
12248 "voice": "default"
12249 }),
12250 )
12251 .await;
12252 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
12253 let body = response_json(response).await;
12254 assert_eq!(body["error"]["type"], "service_unavailable_error");
12255 assert_eq!(body["error"]["param"], Value::Null);
12256 }
12257
12258 #[tokio::test]
12259 async fn route_speech_contract_uses_stub_engine() {
12260 let response = post_json(
12261 router_with_stub_tts(),
12262 "/v1/audio/speech",
12263 json!({
12264 "model": "stub-tts",
12265 "input": "hello",
12266 "voice": "default",
12267 "response_format": "wav",
12268 "language": "english"
12269 }),
12270 )
12271 .await;
12272 assert_eq!(response.status(), AxumStatusCode::OK);
12273 assert_eq!(
12274 response.headers().get(header::CONTENT_TYPE).unwrap(),
12275 "audio/wav"
12276 );
12277 let body = response_bytes(response).await;
12278 assert!(body.len() > 44, "WAV should include header and PCM data");
12279 assert_eq!(&body[0..4], b"RIFF");
12280 assert_eq!(&body[8..12], b"WAVE");
12281 }
12282
12283 #[tokio::test]
12284 async fn route_speech_streaming_contract_uses_stub_engine() {
12285 let response = post_json(
12286 router_with_stub_tts(),
12287 "/v1/audio/speech",
12288 json!({
12289 "model": "stub-tts",
12290 "input": "hello",
12291 "voice": "default",
12292 "response_format": "wav",
12293 "stream": true
12294 }),
12295 )
12296 .await;
12297 assert_eq!(response.status(), AxumStatusCode::OK);
12298 assert_eq!(
12299 response.headers().get(header::CONTENT_TYPE).unwrap(),
12300 "audio/wav"
12301 );
12302 assert_eq!(
12303 response.headers().get(header::TRANSFER_ENCODING).unwrap(),
12304 "chunked"
12305 );
12306 let body = response_bytes(response).await;
12307 assert!(body.len() > 44, "streaming WAV should include audio bytes");
12308 assert_eq!(&body[0..4], b"RIFF");
12309 assert_eq!(&body[8..12], b"WAVE");
12310 }
12311
12312 #[tokio::test]
12313 async fn route_speech_pcm_response_format_returns_raw_pcm() {
12314 let response = post_json(
12315 router_with_stub_tts(),
12316 "/v1/audio/speech",
12317 json!({
12318 "model": "stub-tts",
12319 "input": "hello",
12320 "voice": "default",
12321 "response_format": "pcm"
12322 }),
12323 )
12324 .await;
12325 assert_eq!(response.status(), AxumStatusCode::OK);
12326 assert_eq!(
12327 response.headers().get(header::CONTENT_TYPE).unwrap(),
12328 "audio/pcm"
12329 );
12330 let body = response_bytes(response).await;
12331 assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
12332 assert_eq!(&body[0..2], &[0, 0]);
12333 assert_ne!(&body[0..4], b"RIFF");
12334 }
12335
12336 #[tokio::test]
12337 async fn route_speech_rejects_unsupported_response_format() {
12338 let response = post_json(
12339 router_with_stub_tts(),
12340 "/v1/audio/speech",
12341 json!({
12342 "model": "stub-tts",
12343 "input": "hello",
12344 "voice": "default",
12345 "response_format": "mp3"
12346 }),
12347 )
12348 .await;
12349 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12350 let body = response_json(response).await;
12351 assert_eq!(body["error"]["type"], "invalid_request_error");
12352 assert_eq!(body["error"]["param"], "response_format");
12353 }
12354
12355 #[tokio::test]
12356 async fn route_speech_invalid_json_maps_to_openai_error() {
12357 let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
12358 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12359 let body = response_json(response).await;
12360 assert_eq!(body["error"]["type"], "invalid_request_error");
12361 assert_eq!(body["error"]["param"], Value::Null);
12362 assert!(body["error"]["message"]
12363 .as_str()
12364 .unwrap()
12365 .contains("invalid speech request"));
12366 }
12367
12368 #[tokio::test]
12369 async fn route_completions_generation_failure_maps_to_500() {
12370 let response = post_json(
12371 router_with_failing_llm(),
12372 "/v1/completions",
12373 json!({
12374 "model": "failing-model",
12375 "prompt": "complete me"
12376 }),
12377 )
12378 .await;
12379 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
12380 let body = response_json(response).await;
12381 assert_eq!(body["error"]["type"], "internal_server_error");
12382 assert!(body["error"]["message"]
12383 .as_str()
12384 .unwrap()
12385 .contains("stub generation failed"));
12386 }
12387
12388 #[tokio::test]
12389 async fn route_completions_stream_start_failure_maps_to_500_before_sse() {
12390 let response = post_json(
12391 router_with_failing_llm(),
12392 "/v1/completions",
12393 json!({
12394 "model": "failing-model",
12395 "prompt": "complete me",
12396 "stream": true
12397 }),
12398 )
12399 .await;
12400 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
12401 let body = response_json(response).await;
12402 assert_eq!(body["error"]["type"], "internal_server_error");
12403 assert!(body["error"]["message"]
12404 .as_str()
12405 .unwrap()
12406 .contains("stub stream failed"));
12407 }
12408
12409 #[tokio::test]
12410 async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
12411 let response = post_json(
12412 router_with_stream_chunk_failing_llm(),
12413 "/v1/completions",
12414 json!({
12415 "model": "failing-model",
12416 "prompt": "complete me",
12417 "stream": true
12418 }),
12419 )
12420 .await;
12421 assert_eq!(response.status(), AxumStatusCode::OK);
12422 let body = response_text(response).await;
12423 assert_openai_stream_error(&body, "stub stream chunk failed");
12424 }
12425
12426 #[tokio::test]
12427 async fn route_completions_contract_uses_stub_engine() {
12428 let response = post_json(
12429 router_with_stub("done"),
12430 "/v1/completions",
12431 json!({
12432 "model": "stub-model",
12433 "prompt": "complete me",
12434 "max_tokens": 8,
12435 "temperature": 0.0
12436 }),
12437 )
12438 .await;
12439 assert_eq!(response.status(), AxumStatusCode::OK);
12440 let body = response_json(response).await;
12441 assert_eq!(body["object"], "text_completion");
12442 assert_eq!(body["choices"][0]["text"], "done");
12443 assert_eq!(body["usage"]["prompt_tokens"], 7);
12444 assert_eq!(body["usage"]["completion_tokens"], 2);
12445 }
12446
12447 #[tokio::test]
12448 async fn route_completions_public_alias_maps_to_internal_model() {
12449 let engine = Arc::new(CapturingLlm::new());
12450 let registry = ServedModelRegistry::try_new(
12451 "qwen3",
12452 ServedModelKind::Llm,
12453 vec!["served-alias".to_string()],
12454 vec![],
12455 )
12456 .unwrap();
12457 let router = AxumServer::from_llm(engine.clone())
12458 .with_served_model_registry(registry)
12459 .build_router();
12460 let response = post_json(
12461 router,
12462 "/v1/completions",
12463 json!({"model": "served-alias", "prompt": "complete me"}),
12464 )
12465 .await;
12466
12467 assert_eq!(response.status(), AxumStatusCode::OK);
12468 assert_eq!(response_json(response).await["model"], "served-alias");
12469 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
12470 }
12471
12472 #[tokio::test]
12473 async fn route_completions_streaming_contract_uses_stub_engine() {
12474 let response = post_json(
12475 router_with_stub("done"),
12476 "/v1/completions",
12477 json!({
12478 "model": "stub-model",
12479 "prompt": "complete me",
12480 "max_tokens": 8,
12481 "temperature": 0.0,
12482 "stream": true
12483 }),
12484 )
12485 .await;
12486 assert_eq!(response.status(), AxumStatusCode::OK);
12487 let body = response_text(response).await;
12488 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12489 assert!(
12490 body.contains("\"object\":\"text_completion\""),
12491 "missing completion chunk: {body}"
12492 );
12493 assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
12494 assert!(
12495 body.contains("\"choices\":[],\"usage\""),
12496 "missing separate usage chunk: {body}"
12497 );
12498 assert!(
12499 body.contains("\"prompt_tokens\":5"),
12500 "stream usage should come from engine token usage: {body}"
12501 );
12502 assert!(
12503 body.contains("\"completion_tokens\":1"),
12504 "stream completion usage should come from engine token usage: {body}"
12505 );
12506 }
12507
12508 #[tokio::test]
12509 async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
12510 let response = post_json(
12511 router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
12512 "/v1/completions",
12513 json!({
12514 "model": "stub-model",
12515 "prompt": "complete me",
12516 "max_tokens": 2,
12517 "temperature": 0.0,
12518 "stream": true
12519 }),
12520 )
12521 .await;
12522 assert_eq!(response.status(), AxumStatusCode::OK);
12523 let body = response_text(response).await;
12524 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
12525 assert!(
12526 body.contains("\"text\":\"do\""),
12527 "missing first chunk: {body}"
12528 );
12529 assert!(
12530 body.contains("\"text\":\"ne\""),
12531 "missing second chunk: {body}"
12532 );
12533 assert!(
12534 body.contains("\"choices\":[],\"usage\""),
12535 "missing separate usage chunk from final engine chunk: {body}"
12536 );
12537 assert!(
12538 body.contains("\"prompt_tokens\":5"),
12539 "stream usage should come from engine final usage: {body}"
12540 );
12541 }
12542
12543 #[tokio::test]
12544 async fn route_completions_invalid_json_maps_to_openai_error() {
12545 let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
12546 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12547 let body = response_json(response).await;
12548 assert_eq!(body["error"]["type"], "invalid_request_error");
12549 assert_eq!(body["error"]["param"], Value::Null);
12550 assert!(body["error"]["message"]
12551 .as_str()
12552 .unwrap()
12553 .contains("invalid completions request"));
12554 }
12555
12556 #[tokio::test]
12557 async fn route_completions_rejects_unsupported_fields_explicitly() {
12558 for (extra, param) in [
12559 (json!({"n": 2}), "n"),
12560 (json!({"logprobs": 3}), "logprobs"),
12561 (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
12562 ] {
12563 let mut body = json!({
12564 "model": "stub-model",
12565 "prompt": "complete me"
12566 });
12567 body.as_object_mut()
12568 .expect("object")
12569 .extend(extra.as_object().expect("extra object").clone());
12570 let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
12571 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12572 let body = response_json(response).await;
12573 assert_eq!(body["error"]["type"], "invalid_request_error");
12574 assert_eq!(body["error"]["param"], param);
12575 }
12576 }
12577
12578 #[tokio::test]
12579 async fn streaming_completions_do_not_synthesize_whitespace_usage() {
12580 let response = post_json(
12581 router_with_stub_without_stream_usage("done"),
12582 "/v1/completions",
12583 json!({
12584 "model": "stub-model",
12585 "prompt": "one two three four",
12586 "stream": true
12587 }),
12588 )
12589 .await;
12590 assert_eq!(response.status(), AxumStatusCode::OK);
12591 let body = response_text(response).await;
12592 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12593 assert!(
12594 !body.contains("\"usage\":{\"prompt_tokens\""),
12595 "server must not synthesize whitespace-count completion usage: {body}"
12596 );
12597 }
12598
12599 #[tokio::test]
12600 async fn chat_rejects_n_not_one_with_openai_error_param() {
12601 let request = chat_request(json!({"n": 2}));
12602 let err = chat_completions_handler(
12603 State(state_with_stub("unused")),
12604 HeaderMap::new(),
12605 Ok(Json(request)),
12606 )
12607 .await
12608 .expect_err("n=2 should reject");
12609 let (status, body) = error_json(err).await;
12610 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12611 assert_eq!(body["error"]["type"], "invalid_request_error");
12612 assert_eq!(body["error"]["param"], "n");
12613 }
12614
12615 #[tokio::test]
12616 async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
12617 for (extra, param) in [
12618 (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
12619 (json!({"logprobs": true}), "logprobs"),
12620 (json!({"top_logprobs": 2}), "top_logprobs"),
12621 ] {
12622 let request = chat_request(extra);
12623 let err = chat_completions_handler(
12624 State(state_with_stub("unused")),
12625 HeaderMap::new(),
12626 Ok(Json(request)),
12627 )
12628 .await
12629 .expect_err("unsupported field should reject");
12630 let (status, body) = error_json(err).await;
12631 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12632 assert_eq!(body["error"]["param"], param);
12633 assert_eq!(body["error"]["type"], "invalid_request_error");
12634 }
12635 }
12636
12637 #[tokio::test]
12638 async fn chat_stream_options_include_usage_controls_stream_usage() {
12639 let request = chat_request(json!({
12640 "stream": true,
12641 "stream_options": {"include_usage": true}
12642 }));
12643 let response = chat_completions_handler(
12644 State(state_with_stub("ok")),
12645 HeaderMap::new(),
12646 Ok(Json(request)),
12647 )
12648 .await
12649 .expect("stream response");
12650 assert_eq!(response.status(), AxumStatusCode::OK);
12651 let body = response_text(response).await;
12652 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12653 assert!(
12654 body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
12655 "include_usage=true should emit stream usage: {body}"
12656 );
12657 assert!(
12658 body.contains("\"choices\":[],\"usage\""),
12659 "include_usage=true should use a separate usage chunk: {body}"
12660 );
12661 assert!(
12662 body.contains("\"prompt_tokens\":5"),
12663 "stream usage should come from engine token usage: {body}"
12664 );
12665
12666 let request = chat_request(json!({"stream": true}));
12667 let response = chat_completions_handler(
12668 State(state_with_stub("ok")),
12669 HeaderMap::new(),
12670 Ok(Json(request)),
12671 )
12672 .await
12673 .expect("stream response");
12674 let body = response_text(response).await;
12675 assert!(
12676 !body.contains("\"usage\":{\"prompt_tokens\""),
12677 "stream usage should be omitted unless requested: {body}"
12678 );
12679 }
12680
12681 #[tokio::test]
12682 async fn streaming_chat_does_not_synthesize_whitespace_usage() {
12683 let response = post_json(
12684 router_with_stub_without_stream_usage("ok"),
12685 "/v1/chat/completions",
12686 json!({
12687 "model": "stub-model",
12688 "messages": [{"role": "user", "content": "one two three four"}],
12689 "stream": true,
12690 "stream_options": {"include_usage": true}
12691 }),
12692 )
12693 .await;
12694 assert_eq!(response.status(), AxumStatusCode::OK);
12695 let body = response_text(response).await;
12696 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12697 assert!(
12698 !body.contains("\"usage\":{\"prompt_tokens\""),
12699 "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
12700 );
12701 }
12702
12703 #[test]
12704 fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
12705 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12706 "model": "qwen3",
12707 "messages": [
12708 {"role": "user", "content": "Use the weather tool."},
12709 {
12710 "role": "assistant",
12711 "content": null,
12712 "tool_calls": [{
12713 "id": "call_1",
12714 "type": "function",
12715 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
12716 }]
12717 },
12718 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
12719 ],
12720 "tools": [{
12721 "type": "function",
12722 "function": {
12723 "name": "weather",
12724 "description": "Get weather",
12725 "parameters": {
12726 "type": "object",
12727 "properties": {"city": {"type": "string"}},
12728 "required": ["city"]
12729 }
12730 }
12731 }],
12732 "tool_choice": "auto"
12733 }))
12734 .expect("tool request parses");
12735
12736 validate_chat_request(&request).expect("tool request validates");
12737 let internal = convert_chat_request(&request).expect("convert");
12738 assert!(internal.prompt.contains("\"tools\":[{"));
12739 assert!(internal.prompt.contains("\"type\":\"function\""));
12740 assert!(internal.prompt.contains("\"name\":\"weather\""));
12741 assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
12742 assert!(internal.prompt.contains("\"tool_calls\":[{"));
12743 assert!(internal.prompt.contains("\"id\":\"call_1\""));
12744 assert!(internal
12745 .prompt
12746 .contains("<|im_start|>tool\nsunny<|im_end|>"));
12747 assert_eq!(
12748 internal.metadata["openai_tools"][0]["function"]["name"],
12749 "weather"
12750 );
12751 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12752 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12753 panic!("expected structured chat api_request");
12754 };
12755 assert_eq!(api.messages.len(), 3);
12756 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
12757 assert_eq!(api.messages[2].content, "sunny");
12758 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
12759 assert_eq!(api.tools[0].function.name, "weather");
12760 assert_eq!(
12761 api.tool_choice,
12762 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12763 );
12764 assert_eq!(
12765 api.messages[1].tool_calls[0].function.arguments,
12766 "{\"city\":\"Paris\"}"
12767 );
12768 }
12769
12770 #[test]
12771 fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
12772 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12773 "model": "served-alias",
12774 "messages": [{"role": "user", "content": "Use the weather tool."}],
12775 "tools": [{
12776 "type": "function",
12777 "function": {
12778 "name": "weather",
12779 "description": "Get weather",
12780 "parameters": {
12781 "type": "object",
12782 "properties": {"city": {"type": "string"}},
12783 "required": ["city"]
12784 }
12785 }
12786 }]
12787 }))
12788 .expect("tool request parses");
12789
12790 validate_chat_request(&request).expect("tool request validates");
12791 let internal = convert_chat_request(&request).expect("convert");
12792 assert!(internal.prompt.contains("\"tools\":[{"));
12793 assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
12794 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12795 let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
12796 .as_array()
12797 .expect("initial forbidden token list");
12798 assert_eq!(initial_forbidden, &[serde_json::json!(THINK_END_TAG)]);
12799 assert_eq!(
12800 internal.sampling_params.response_format,
12801 ferrum_types::ResponseFormat::Text,
12802 "auto tool choice must preserve native model selection instead of forcing arguments JSON",
12803 );
12804 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12805 panic!("expected structured chat api_request");
12806 };
12807 assert_eq!(
12808 api.tool_choice,
12809 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12810 );
12811 }
12812
12813 #[test]
12814 fn omitted_tool_choice_uses_native_template_protocol_without_hard_schema() {
12815 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12816 "model": "served-alias",
12817 "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
12818 "tools": [{
12819 "type": "function",
12820 "function": {
12821 "name": "get_weather",
12822 "description": "查询指定城市的当前天气",
12823 "parameters": {
12824 "type": "object",
12825 "properties": {
12826 "city": {"type": "string"},
12827 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12828 },
12829 "required": ["city"]
12830 }
12831 }
12832 }]
12833 }))
12834 .expect("tool request parses");
12835 let template = ModelChatTemplate::new(
12836 "{% if tools %}<tools>{{ tools | tojson }}</tools>Use <tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
12837 "function-parameter-xml-template",
12838 );
12839
12840 validate_chat_request(&request).expect("tool request validates");
12841 let internal =
12842 convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
12843 .expect("convert");
12844 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12845 assert_eq!(
12846 internal.sampling_params.response_format,
12847 ferrum_types::ResponseFormat::Text,
12848 );
12849 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
12850 panic!("expected chat API request");
12851 };
12852 assert_eq!(
12853 api.tool_call_protocol,
12854 ferrum_types::ApiToolCallProtocol::FunctionParameterXml,
12855 );
12856 }
12857
12858 #[test]
12859 fn tool_schema_response_format_bounds_unconstrained_strings() {
12860 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12861 "model": "served-alias",
12862 "messages": [{"role": "user", "content": "Use the selected tool."}],
12863 "tools": [{
12864 "type": "function",
12865 "function": {
12866 "name": "get_weather",
12867 "parameters": {
12868 "type": "object",
12869 "properties": {
12870 "city": {"type": "string"},
12871 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12872 },
12873 "required": ["city"]
12874 }
12875 }
12876 }],
12877 "tool_choice": {
12878 "type": "function",
12879 "function": {"name": "get_weather"}
12880 }
12881 }))
12882 .expect("tool request parses");
12883
12884 validate_chat_request(&request).expect("tool request validates");
12885 let internal = convert_chat_request(&request).expect("convert");
12886 match internal.sampling_params.response_format {
12887 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
12888 let value: serde_json::Value =
12889 serde_json::from_str(schema).expect("schema should be JSON");
12890 assert_eq!(
12891 value["properties"]["city"]["maxLength"],
12892 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
12893 );
12894 assert_eq!(
12895 value["properties"]["unit"]["enum"],
12896 json!(["celsius", "fahrenheit"])
12897 );
12898 assert!(
12899 value["properties"]["unit"]["maxLength"].is_null(),
12900 "enum string should remain finite via enum instead of maxLength: {value}"
12901 );
12902 }
12903 ref other => panic!("expected forced tool json schema, got {other:?}"),
12904 }
12905 }
12906
12907 #[test]
12908 fn forced_native_tool_choice_preserves_xml_framing_with_a_tool_only_grammar() {
12909 let template = ModelChatTemplate::new(
12910 "{% if tools %}<tools>{{ tools | tojson }}</tools>Use <tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
12911 "native-tool-fixture",
12912 );
12913 for choice in [
12914 json!({"type":"function","function":{"name":"calc"}}),
12915 json!("required"),
12916 ] {
12917 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12918 "model":"served-alias","messages":[{"role":"user","content":"Use the selected tool."}],
12919 "tools":[
12920 {"type":"function","function":{"name":"calc","parameters":{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}}},
12921 {"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}
12922 ],
12923 "tool_choice":choice
12924 })).unwrap();
12925 validate_chat_request(&request).unwrap();
12926 let internal =
12927 convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
12928 .unwrap();
12929 assert!(internal.requires_structured_output());
12930 assert_eq!(
12931 internal.sampling_params.response_format,
12932 ferrum_types::ResponseFormat::Text
12933 );
12934 assert!(internal.prompt.contains("<function=name>"));
12935 let Some(ferrum_types::ApiRequest::Chat(chat)) = &internal.api_request else {
12936 panic!("chat contract")
12937 };
12938 assert!(chat.requires_native_tool_call());
12939 assert!(chat.allows_tool_name("calc"));
12940 assert_eq!(chat.allows_tool_name("lookup"), choice == json!("required"));
12941 assert_eq!(
12942 internal.sampling_params.structured_output_start,
12943 StructuredOutputStart::Immediate
12944 );
12945 }
12946 }
12947
12948 #[test]
12949 fn harmony_named_tool_choice_preserves_native_protocol_envelope() {
12950 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12951 "model": "gpt-oss-20b-mxfp4",
12952 "messages": [{
12953 "role": "user",
12954 "content": "Call get_weather exactly once with city set to Paris."
12955 }],
12956 "tools": [{
12957 "type": "function",
12958 "function": {
12959 "name": "get_weather",
12960 "parameters": {
12961 "type": "object",
12962 "properties": {"city": {"type": "string"}},
12963 "required": ["city"],
12964 "additionalProperties": false
12965 }
12966 }
12967 }],
12968 "tool_choice": {
12969 "type": "function",
12970 "function": {"name": "get_weather"}
12971 }
12972 }))
12973 .expect("Harmony tool request parses");
12974 let mut template = ModelChatTemplate::new(
12975 "{% if tools %}<|start|>developer<|message|>{{ tools | tojson }}<|end|>{% endif %}{% for message in messages %}<|start|>{{ message.role }}<|message|>{{ message.content }}<|end|>{% endfor %}{% if add_generation_prompt %}<|start|>assistant{% endif %}",
12976 "harmony-tool-template",
12977 );
12978 template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
12979
12980 validate_chat_request(&request).expect("Harmony tool request validates");
12981 let internal =
12982 convert_chat_request_with_template_model(&request, "gpt-oss-20b", Some(&template))
12983 .expect("convert Harmony tool request");
12984
12985 assert!(internal.prompt.ends_with("<|start|>assistant"));
12986 assert_eq!(
12987 internal.sampling_params.model_output_protocol,
12988 ModelOutputProtocol::HarmonyGptOss
12989 );
12990 assert_eq!(
12991 internal.sampling_params.response_format,
12992 ferrum_types::ResponseFormat::Text,
12993 "Harmony must generate its channel/message/call envelope before tool arguments"
12994 );
12995 assert_eq!(
12996 internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY],
12997 json!([]),
12998 "Harmony declares no think delimiter and must not receive the generic structured-call mask"
12999 );
13000 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
13001 panic!("expected chat API request");
13002 };
13003 assert_eq!(
13004 api.tool_choice,
13005 Some(ferrum_types::ApiToolChoice::Function {
13006 tool_type: "function".to_string(),
13007 function: ferrum_types::ApiToolChoiceFunction {
13008 name: "get_weather".to_string(),
13009 },
13010 })
13011 );
13012 }
13013
13014 #[test]
13015 fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
13016 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13017 "model": "served-alias",
13018 "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
13019 "tools": [{
13020 "type": "function",
13021 "function": {
13022 "name": "capture_quality_marker",
13023 "description": "Record one marker.",
13024 "parameters": {
13025 "type": "object",
13026 "properties": {
13027 "marker": {"type": "string", "enum": ["ferrum0401"]},
13028 "checksum": {"type": "string", "enum": ["S0004"]}
13029 },
13030 "required": ["marker", "checksum"]
13031 }
13032 }
13033 }],
13034 "tool_choice": "required"
13035 }))
13036 .expect("tool request parses");
13037
13038 validate_chat_request(&request).expect("tool request validates");
13039 let internal = convert_chat_request(&request).expect("convert");
13040
13041 assert!(
13042 !internal.prompt.contains(
13043 "Output only a single JSON object containing the selected function arguments"
13044 ),
13045 "{}",
13046 internal.prompt
13047 );
13048 assert!(
13049 internal.prompt.contains("\"tool_choice\":\"required\""),
13050 "{}",
13051 internal.prompt
13052 );
13053 assert_eq!(internal.metadata["openai_tool_choice"], "required");
13054 match internal.sampling_params.response_format {
13055 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
13056 assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
13057 assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
13058 }
13059 ref other => panic!("expected forced tool json schema, got {other:?}"),
13060 }
13061 }
13062
13063 #[test]
13064 fn required_tool_choice_suppresses_conflicting_response_format_instruction() {
13065 let request: ChatCompletionsRequest =
13066 serde_json::from_value(required_tool_with_strict_response_format_request(false))
13067 .expect("request parses");
13068
13069 validate_chat_request(&request).expect("request validates");
13070 let internal = convert_chat_request(&request).expect("convert");
13071
13072 assert!(
13073 !internal.prompt.contains("response_format requires"),
13074 "required tool output must not receive a conflicting content-schema instruction: {}",
13075 internal.prompt
13076 );
13077 let ferrum_types::ResponseFormat::JsonSchema(schema) =
13078 internal.sampling_params.response_format
13079 else {
13080 panic!("single required tool must use its argument schema");
13081 };
13082 let schema: Value = serde_json::from_str(&schema).expect("tool schema JSON");
13083 assert!(schema["properties"].get("city").is_some(), "{schema}");
13084 assert!(schema["properties"].get("answer").is_none(), "{schema}");
13085 }
13086
13087 #[test]
13088 fn required_multiple_tools_do_not_force_the_first_tool_schema() {
13089 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13090 "model": "stub-model",
13091 "messages": [{"role": "user", "content": "Use the appropriate tool."}],
13092 "tools": [
13093 {
13094 "type": "function",
13095 "function": {
13096 "name": "weather",
13097 "parameters": {
13098 "type": "object",
13099 "properties": {"city": {"type": "string"}},
13100 "required": ["city"]
13101 }
13102 }
13103 },
13104 {
13105 "type": "function",
13106 "function": {
13107 "name": "calendar",
13108 "parameters": {
13109 "type": "object",
13110 "properties": {"date": {"type": "string"}},
13111 "required": ["date"]
13112 }
13113 }
13114 }
13115 ],
13116 "tool_choice": "required"
13117 }))
13118 .expect("request parses");
13119
13120 validate_chat_request(&request).expect("request validates");
13121 let internal = convert_chat_request(&request).expect("convert");
13122 assert_eq!(
13123 internal.sampling_params.response_format,
13124 ferrum_types::ResponseFormat::Text,
13125 "required permits either declared tool, so guided decoding cannot bind the first tool's arguments"
13126 );
13127 }
13128
13129 #[test]
13130 fn omitted_single_unrelated_tool_keeps_text_response_format() {
13131 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13132 "model": "served-alias",
13133 "messages": [{"role": "user", "content": "讲一个短笑话。"}],
13134 "tools": [{
13135 "type": "function",
13136 "function": {
13137 "name": "get_weather",
13138 "description": "查询指定城市的当前天气",
13139 "parameters": {
13140 "type": "object",
13141 "properties": {"city": {"type": "string"}},
13142 "required": ["city"]
13143 }
13144 }
13145 }]
13146 }))
13147 .expect("tool request parses");
13148
13149 validate_chat_request(&request).expect("tool request validates");
13150 let internal = convert_chat_request(&request).expect("convert");
13151 assert_eq!(
13152 internal.sampling_params.response_format,
13153 ferrum_types::ResponseFormat::Text
13154 );
13155 }
13156
13157 #[test]
13158 fn tool_choice_none_omits_tools_from_model_template_prompt() {
13159 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13160 "model": "served-alias",
13161 "messages": [
13162 {"role": "user", "content": "Use the weather tool if needed."},
13163 {
13164 "role": "assistant",
13165 "content": null,
13166 "tool_calls": [{
13167 "id": "call_1",
13168 "type": "function",
13169 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
13170 }]
13171 },
13172 {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
13173 ],
13174 "tools": [{
13175 "type": "function",
13176 "function": {"name": "weather", "parameters": {"type": "object"}}
13177 }],
13178 "tool_choice": "none"
13179 }))
13180 .expect("tool_choice none request parses");
13181 let template = ModelChatTemplate::new(
13182 "{% set tools_in_user_message = true %}{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
13183 "tool-choice-none-template",
13184 );
13185
13186 validate_chat_request(&request).expect("tool_choice none request validates");
13187 let internal = convert_chat_request_with_template_model(
13188 &request,
13189 "served-template-model",
13190 Some(&template),
13191 )
13192 .expect("convert");
13193 assert!(
13194 !internal.prompt.contains("<tools>"),
13195 "tool_choice none must not expose tools to the model template: {}",
13196 internal.prompt
13197 );
13198 assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
13199 assert_eq!(
13200 internal.metadata["openai_tools"][0]["function"]["name"],
13201 "weather"
13202 );
13203 assert_eq!(internal.metadata["openai_tool_choice"], "none");
13204 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13205 panic!("expected structured chat api_request");
13206 };
13207 assert_eq!(api.tools[0].function.name, "weather");
13208 assert_eq!(
13209 api.tool_choice,
13210 Some(ferrum_types::ApiToolChoice::Mode("none".into()))
13211 );
13212 }
13213
13214 #[test]
13215 fn specific_tool_choice_parses_into_structured_api_request() {
13216 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13217 "model": "qwen3",
13218 "messages": [{"role": "user", "content": "Use the selected tool."}],
13219 "tools": [
13220 {
13221 "type": "function",
13222 "function": {"name": "weather", "parameters": {"type": "object"}}
13223 },
13224 {
13225 "type": "function",
13226 "function": {"name": "calendar", "parameters": {"type": "object"}}
13227 }
13228 ],
13229 "tool_choice": {
13230 "type": "function",
13231 "function": {"name": "weather"}
13232 }
13233 }))
13234 .expect("specific tool_choice request parses");
13235
13236 validate_chat_request(&request).expect("specific tool_choice validates");
13237 let internal = convert_chat_request(&request).expect("convert");
13238 assert!(internal.prompt.contains("\"tool_choice\":{"));
13239 assert!(internal.prompt.contains("\"name\":\"weather\""));
13240 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13241 panic!("expected structured chat api_request");
13242 };
13243 assert_eq!(
13244 api.tool_choice,
13245 Some(ferrum_types::ApiToolChoice::Function {
13246 tool_type: "function".to_string(),
13247 function: ferrum_types::ApiToolChoiceFunction {
13248 name: "weather".to_string()
13249 },
13250 })
13251 );
13252
13253 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
13254 "model": "qwen3",
13255 "messages": [{"role": "user", "content": "Use the selected tool."}],
13256 "tools": [{
13257 "type": "function",
13258 "function": {"name": "weather", "parameters": {"type": "object"}}
13259 }],
13260 "tool_choice": {
13261 "type": "function",
13262 "function": {"name": "calendar"}
13263 }
13264 }))
13265 .expect("invalid specific tool_choice request parses");
13266 let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
13267 match err {
13268 ServerError::InvalidRequest { param, .. } => {
13269 assert_eq!(param.as_deref(), Some("tool_choice"));
13270 }
13271 other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
13272 }
13273 }
13274
13275 #[test]
13276 fn legacy_function_role_messages_parse_into_structured_api_request() {
13277 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13278 "model": "mystery-model",
13279 "messages": [
13280 {"role": "user", "content": "Call weather."},
13281 {
13282 "role": "assistant",
13283 "content": null,
13284 "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
13285 },
13286 {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
13287 ],
13288 "functions": [{
13289 "name": "weather",
13290 "parameters": {
13291 "type": "object",
13292 "properties": {"city": {"type": "string"}},
13293 "required": ["city"]
13294 }
13295 }],
13296 "function_call": "auto"
13297 }))
13298 .expect("legacy function request parses");
13299
13300 validate_chat_request(&request).expect("legacy function request validates");
13301 let internal = convert_chat_request(&request).expect("convert");
13302 assert!(
13303 internal
13304 .prompt
13305 .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
13306 "legacy function role should be preserved in fallback template: {}",
13307 internal.prompt
13308 );
13309 assert_eq!(
13310 internal.metadata["openai_legacy_functions"][0]["name"],
13311 "weather"
13312 );
13313 assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
13314 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13315 panic!("expected structured chat api_request");
13316 };
13317 assert_eq!(api.messages.len(), 3);
13318 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
13319 assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
13320 assert_eq!(
13321 api.messages[1]
13322 .function_call
13323 .as_ref()
13324 .map(|call| call.name.as_str()),
13325 Some("weather")
13326 );
13327 assert_eq!(api.legacy_functions[0].name, "weather");
13328 assert_eq!(
13329 api.legacy_function_call,
13330 Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
13331 );
13332 }
13333
13334 #[test]
13335 fn specific_legacy_function_call_parses_into_structured_api_request() {
13336 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13337 "model": "mystery-model",
13338 "messages": [{"role": "user", "content": "Use the selected function."}],
13339 "functions": [
13340 {"name": "weather", "parameters": {"type": "object"}},
13341 {"name": "calendar", "parameters": {"type": "object"}}
13342 ],
13343 "function_call": {"name": "weather"}
13344 }))
13345 .expect("specific function_call request parses");
13346
13347 validate_chat_request(&request).expect("specific function_call validates");
13348 let internal = convert_chat_request(&request).expect("convert");
13349 assert!(internal.prompt.contains("\"function_call\":{"));
13350 assert!(internal.prompt.contains("\"name\":\"weather\""));
13351 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13352 panic!("expected structured chat api_request");
13353 };
13354 assert_eq!(
13355 api.legacy_function_call,
13356 Some(ferrum_types::ApiFunctionCallChoice::Function {
13357 name: "weather".to_string(),
13358 })
13359 );
13360
13361 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
13362 "model": "mystery-model",
13363 "messages": [{"role": "user", "content": "Use the selected function."}],
13364 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
13365 "function_call": {"name": "calendar"}
13366 }))
13367 .expect("invalid specific function_call request parses");
13368 let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
13369 match err {
13370 ServerError::InvalidRequest { param, .. } => {
13371 assert_eq!(param.as_deref(), Some("function_call"));
13372 }
13373 other => panic!("expected invalid_request_error for function_call, got {other:?}"),
13374 }
13375 }
13376
13377 #[test]
13378 fn stream_text_delta_handles_unicode_boundaries() {
13379 let mut sent_len = 0usize;
13380 assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
13381 assert_eq!(sent_len, "你好".len());
13382 assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
13383 assert_eq!(sent_len, "你好世界".len());
13384 }
13385
13386 #[test]
13387 fn stream_text_delta_recovers_from_non_boundary_offset() {
13388 let mut sent_len = 1usize;
13389 assert_eq!(stream_text_delta("你好", &mut sent_len), "");
13390 assert_eq!(sent_len, "你好".len());
13391 }
13392
13393 #[test]
13394 fn assistant_tool_call_serializes_openai_shape() {
13395 let message = ChatMessage {
13396 role: MessageRole::Assistant,
13397 content: String::new(),
13398 reasoning: None,
13399 name: None,
13400 tool_calls: Some(vec![ChatToolCall {
13401 index: None,
13402 id: "call_1".to_string(),
13403 tool_type: "function".to_string(),
13404 function: ChatFunctionCall {
13405 name: "weather".to_string(),
13406 arguments: "{\"city\":\"Paris\"}".to_string(),
13407 },
13408 }]),
13409 tool_call_id: None,
13410 function_call: None,
13411 };
13412 let value = serde_json::to_value(message).expect("serialize");
13413 assert_eq!(value["role"], "assistant");
13414 assert_eq!(value["tool_calls"][0]["type"], "function");
13415 assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
13416 }
13417
13418 #[test]
13419 fn unsupported_multimodal_content_is_not_silently_dropped() {
13420 let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
13421 "model": "stub-model",
13422 "messages": [{
13423 "role": "user",
13424 "content": [
13425 {"type": "text", "text": "describe this"},
13426 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
13427 ]
13428 }]
13429 }))
13430 .expect_err("unsupported content part should fail parsing");
13431 assert!(
13432 err.to_string()
13433 .contains("unsupported message content part type"),
13434 "unexpected error: {err}"
13435 );
13436 }
13437
13438 #[tokio::test]
13439 async fn completions_endpoint_uses_stub_engine() {
13440 let request = CompletionsRequest {
13441 model: "stub-model".to_string(),
13442 prompt: CompletionPrompt::Text("complete me".to_string()),
13443 max_tokens: Some(8),
13444 temperature: Some(0.0),
13445 top_p: None,
13446 n: None,
13447 stream: None,
13448 stop: None,
13449 logprobs: None,
13450 logit_bias: None,
13451 };
13452 let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
13453 .await
13454 .expect("completion response");
13455 assert_eq!(response.status(), AxumStatusCode::OK);
13456 let body = response_json(response).await;
13457 assert_eq!(body["object"], "text_completion");
13458 assert_eq!(body["choices"][0]["text"], "done");
13459 assert_eq!(body["usage"]["prompt_tokens"], 7);
13460 assert_eq!(body["usage"]["completion_tokens"], 2);
13461 }
13462
13463 #[tokio::test]
13464 async fn route_completions_rejects_non_string_prompt_with_field_param() {
13465 for prompt in [
13466 json!(["a", "b"]),
13467 json!({"text": "complete me"}),
13468 Value::Null,
13469 ] {
13470 let response = post_json(
13471 router_with_stub("unused"),
13472 "/v1/completions",
13473 json!({
13474 "model": "stub-model",
13475 "prompt": prompt
13476 }),
13477 )
13478 .await;
13479 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13480 let body = response_json(response).await;
13481 assert_eq!(body["error"]["type"], "invalid_request_error");
13482 assert_eq!(body["error"]["param"], "prompt");
13483 }
13484
13485 let response = post_json(
13486 router_with_stub("unused"),
13487 "/v1/completions",
13488 json!({"model": "stub-model"}),
13489 )
13490 .await;
13491 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13492 let body = response_json(response).await;
13493 assert_eq!(body["error"]["type"], "invalid_request_error");
13494 assert_eq!(body["error"]["param"], "prompt");
13495 }
13496
13497 #[tokio::test]
13498 async fn stream_options_without_stream_is_invalid() {
13499 let request = chat_request(json!({"stream_options": {"include_usage": true}}));
13500 let err = chat_completions_handler(
13501 State(state_with_stub("unused")),
13502 HeaderMap::new(),
13503 Ok(Json(request)),
13504 )
13505 .await
13506 .expect_err("stream_options without stream should reject");
13507 let (status, body) = error_json(err).await;
13508 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13509 assert_eq!(body["error"]["param"], "stream_options");
13510 assert_eq!(body["error"]["type"], "invalid_request_error");
13511 }
13512
13513 #[tokio::test]
13514 async fn unknown_stream_option_is_rejected_instead_of_ignored() {
13515 let response = post_json(
13516 router_with_stub("unused"),
13517 "/v1/chat/completions",
13518 json!({
13519 "model": "stub-model",
13520 "messages": [{"role": "user", "content": "hello"}],
13521 "stream": true,
13522 "stream_options": {"continuous_usage_stats": true}
13523 }),
13524 )
13525 .await;
13526
13527 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13528 let body = response_json(response).await;
13529 assert_eq!(body["error"]["type"], "invalid_request_error");
13530 assert!(
13531 body["error"]["message"]
13532 .as_str()
13533 .unwrap_or_default()
13534 .contains("invalid chat completions request"),
13535 "body: {body}"
13536 );
13537 }
13538
13539 #[tokio::test]
13540 async fn json_object_rejects_markdown_fence_instead_of_repairing() {
13541 let request = chat_request(json!({
13542 "response_format": {"type": "json_object"}
13543 }));
13544 let err = chat_completions_handler(
13545 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
13546 HeaderMap::new(),
13547 Ok(Json(request)),
13548 )
13549 .await
13550 .expect_err("fenced json_object must fail");
13551 let (status, body) = error_json(err).await;
13552 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13553 assert_eq!(body["error"]["type"], "internal_server_error");
13554 assert!(body["error"]["message"]
13555 .as_str()
13556 .unwrap_or_default()
13557 .contains("response_format.json_object: invalid JSON"));
13558 }
13559
13560 #[tokio::test]
13561 async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
13562 let response = post_json(
13563 router_with_stub_stream_chunks(&[
13564 "<think>\n好的,我需要输出 JSON。",
13565 "\n</think>\n\n",
13566 "{\"name\":\"李四\",\"age\":30}",
13567 ]),
13568 "/v1/chat/completions",
13569 json!({
13570 "model": "stub-model",
13571 "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
13572 "stream": true,
13573 "response_format": {"type": "json_object"}
13574 }),
13575 )
13576 .await;
13577 assert_eq!(response.status(), AxumStatusCode::OK);
13578 let body = response_text(response).await;
13579 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
13580 assert!(
13581 body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
13582 "stream should emit clean JSON content: {body}"
13583 );
13584 assert!(
13585 body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
13586 "stream should keep thinking in reasoning field: {body}"
13587 );
13588 assert!(
13589 !body.contains(r#""content":"<think"#)
13590 && !body.contains(r#""content":"好的"#)
13591 && !body.contains(r#""content":"我需要"#),
13592 "thinking text must not leak as streamed content: {body}"
13593 );
13594 }
13595
13596 fn prompt_opened_literal_json_template() -> ModelChatTemplate {
13597 let template = ModelChatTemplate::new(
13598 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>{% endif %}",
13599 "prompt-opened-text-test",
13600 );
13601 assert_eq!(template.output_protocol, ModelOutputProtocol::Text);
13602 assert_eq!(
13603 template.reasoning_protocol,
13604 ModelReasoningProtocol::PromptOpened
13605 );
13606 let request = chat_request(json!({"response_format": {"type": "json_object"}}));
13607 let internal =
13608 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13609 .expect("convert prompt-opened Text request");
13610 assert!(internal.prompt.ends_with("<think>"));
13611 template
13612 }
13613
13614 #[tokio::test]
13615 async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sync() {
13616 let response = post_json(
13617 router_with_stub_and_template(
13618 "reason</think>\n{\"text\":\"<think>literal</think>\"}",
13619 prompt_opened_literal_json_template(),
13620 ),
13621 "/v1/chat/completions",
13622 json!({
13623 "model": "stub-model",
13624 "messages": [{"role": "user", "content": "Return a JSON object."}],
13625 "response_format": {"type": "json_object"}
13626 }),
13627 )
13628 .await;
13629 let status = response.status();
13630 let body = response_json(response).await;
13631 assert_eq!(status, AxumStatusCode::OK, "{body}");
13632 assert!(body.get("error").is_none(), "{body}");
13633 let message = &body["choices"][0]["message"];
13634 assert_eq!(message["content"], r#"{"text":"<think>literal</think>"}"#);
13635 assert_eq!(message["reasoning"], "reason");
13636 assert_eq!(body["choices"][0]["finish_reason"], "stop");
13637 }
13638
13639 #[tokio::test]
13640 async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sse() {
13641 for chunks in [
13642 vec!["reason</think>\n{\"text\":\"<think>literal</think>\"}"],
13643 vec![
13644 "reason</thi",
13645 "nk>\n{\"text\":\"<thi",
13646 "nk>literal</thi",
13647 "nk>\"}",
13648 ],
13649 ] {
13650 let router = AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(&chunks)))
13651 .with_prompt_template(Some(prompt_opened_literal_json_template()))
13652 .build_router();
13653 let response = post_json(
13654 router,
13655 "/v1/chat/completions",
13656 json!({
13657 "model": "stub-model",
13658 "messages": [{"role": "user", "content": "Return a JSON object."}],
13659 "stream": true,
13660 "stream_options": {"include_usage": true},
13661 "response_format": {"type": "json_object"}
13662 }),
13663 )
13664 .await;
13665 let status = response.status();
13666 let body = response_text(response).await;
13667 assert_eq!(status, AxumStatusCode::OK, "{body}");
13668 let normalized = body.replace("\r\n", "\n");
13669 assert_eq!(normalized.matches("data: [DONE]").count(), 1, "{body}");
13670 assert!(normalized.ends_with("data: [DONE]\n\n"), "{body}");
13671 let events = responses_sse_json_events(&body);
13672 assert!(
13673 events.iter().all(|event| event.get("error").is_none()),
13674 "{body}"
13675 );
13676 let content: String = events
13677 .iter()
13678 .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
13679 .collect();
13680 let reasoning: String = events
13681 .iter()
13682 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
13683 .collect();
13684 assert_eq!(content, r#"{"text":"<think>literal</think>"}"#);
13685 assert_eq!(reasoning, "reason");
13686 assert_eq!(
13687 serde_json::from_str::<Value>(&content).expect("intact JSON body"),
13688 json!({"text": "<think>literal</think>"})
13689 );
13690 let terminals: Vec<_> = events
13691 .iter()
13692 .enumerate()
13693 .filter(|(_, event)| !event["choices"][0]["finish_reason"].is_null())
13694 .collect();
13695 assert_eq!(terminals.len(), 1, "{body}");
13696 let (terminal_index, terminal) = terminals[0];
13697 assert_eq!(terminal["choices"][0]["finish_reason"], "stop");
13698 for event in &events[terminal_index..] {
13699 for field in ["content", "reasoning", "reasoning_content"] {
13700 assert!(
13701 event["choices"][0]["delta"][field]
13702 .as_str()
13703 .unwrap_or_default()
13704 .is_empty(),
13705 "payload after terminal: {event}"
13706 );
13707 }
13708 }
13709 let usages: Vec<_> = events
13710 .iter()
13711 .enumerate()
13712 .filter(|(_, event)| !event["usage"].is_null())
13713 .collect();
13714 assert_eq!(usages.len(), 1, "{body}");
13715 let (usage_index, usage) = usages[0];
13716 assert!(terminal_index < usage_index, "{body}");
13717 assert_eq!(usage_index, events.len() - 1, "usage must be last: {body}");
13718 assert_eq!(usage["choices"], json!([]));
13719 }
13720 }
13721
13722 #[tokio::test]
13723 async fn json_object_rejects_non_json_model_output() {
13724 let request = chat_request(json!({
13725 "response_format": {"type": "json_object"}
13726 }));
13727 let err = chat_completions_handler(
13728 State(state_with_stub("not json")),
13729 HeaderMap::new(),
13730 Ok(Json(request)),
13731 )
13732 .await
13733 .expect_err("invalid json_object must fail");
13734 let (status, body) = error_json(err).await;
13735 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13736 assert_eq!(body["error"]["type"], "internal_server_error");
13737 assert!(body["error"]["message"]
13738 .as_str()
13739 .unwrap_or_default()
13740 .contains("response_format.json_object"));
13741 }
13742
13743 #[test]
13744 fn one_of_strict_json_schema_reaches_hard_decoder() {
13745 let request = chat_request(json!({
13746 "response_format": {
13747 "type": "json_schema",
13748 "json_schema": {
13749 "name": "unsupported",
13750 "strict": true,
13751 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13752 }
13753 }
13754 }));
13755 validate_chat_request(&request).expect("oneOf strict schema should validate");
13756 let internal = convert_chat_request(&request).expect("convert oneOf strict schema");
13757 let ferrum_types::ResponseFormat::JsonSchema(schema) =
13758 internal.sampling_params.response_format
13759 else {
13760 panic!("strict schema did not reach hard decoder");
13761 };
13762 assert_eq!(
13763 serde_json::from_str::<serde_json::Value>(&schema).unwrap()["oneOf"],
13764 json!([{"type": "string"}, {"type": "integer"}])
13765 );
13766 let schema = serde_json::from_str::<serde_json::Value>(&schema).unwrap();
13767 validate_json_text_against_schema(&schema, r#""answer""#)
13768 .expect("oneOf string branch should pass final validation");
13769 validate_json_text_against_schema(&schema, "7")
13770 .expect("oneOf integer branch should pass final validation");
13771 assert!(validate_json_text_against_schema(&schema, "true").is_err());
13772 }
13773
13774 #[tokio::test]
13775 async fn missing_json_schema_schema_rejects_with_field_param() {
13776 let request = chat_request(json!({
13777 "response_format": {
13778 "type": "json_schema",
13779 "json_schema": {
13780 "name": "missing_schema",
13781 "strict": true
13782 }
13783 }
13784 }));
13785 let err = chat_completions_handler(
13786 State(state_with_stub("unused")),
13787 HeaderMap::new(),
13788 Ok(Json(request)),
13789 )
13790 .await
13791 .expect_err("missing strict schema should reject");
13792 let (status, body) = error_json(err).await;
13793 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13794 assert_eq!(body["error"]["param"], "response_format.json_schema");
13795 assert_eq!(body["error"]["type"], "invalid_request_error");
13796 assert!(body["error"]["message"]
13797 .as_str()
13798 .unwrap()
13799 .contains("schema is required"));
13800 }
13801
13802 #[test]
13803 fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
13804 let request = chat_request(json!({
13805 "response_format": {
13806 "type": "json_schema",
13807 "json_schema": {
13808 "name": "best_effort",
13809 "strict": false,
13810 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13811 }
13812 }
13813 }));
13814
13815 validate_chat_request(&request).expect("non-strict schema should not boundary reject");
13816 let internal = convert_chat_request(&request).expect("convert non-strict schema");
13817 assert!(
13818 internal
13819 .prompt
13820 .contains("response_format requires a single valid JSON value"),
13821 "response_format instruction should reach the model prompt: {}",
13822 internal.prompt
13823 );
13824 assert!(
13825 internal.prompt.contains("\"oneOf\""),
13826 "schema should reach the model prompt: {}",
13827 internal.prompt
13828 );
13829 assert_eq!(
13830 internal.sampling_params.response_format,
13831 ferrum_types::ResponseFormat::Text,
13832 "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
13833 );
13834 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13835 panic!("expected structured chat api_request");
13836 };
13837 assert_eq!(
13838 api.response_format
13839 .as_ref()
13840 .and_then(|format| format.json_schema.as_ref())
13841 .and_then(|schema| schema.strict),
13842 Some(false)
13843 );
13844 }
13845
13846 #[test]
13847 fn json_object_response_format_instruction_reaches_model_prompt() {
13848 let request = chat_request(json!({
13849 "response_format": {"type": "json_object"}
13850 }));
13851
13852 let internal = convert_chat_request(&request).expect("convert json_object");
13853 assert!(
13854 internal
13855 .prompt
13856 .contains("response_format requires a single valid JSON object"),
13857 "response_format instruction should reach the model prompt: {}",
13858 internal.prompt
13859 );
13860 assert!(
13861 internal.prompt.contains("Output only JSON"),
13862 "JSON-only instruction should reach the model prompt: {}",
13863 internal.prompt
13864 );
13865 assert_eq!(
13866 internal.sampling_params.response_format,
13867 ferrum_types::ResponseFormat::JsonObject,
13868 "json_object must reach the tokenizer-aware hard decoder"
13869 );
13870 assert_eq!(
13871 internal.sampling_params.structured_output_start,
13872 StructuredOutputStart::Immediate
13873 );
13874 }
13875
13876 fn harmony_json_template() -> ModelChatTemplate {
13877 let mut template = ModelChatTemplate::new(
13878 "{% for message in messages %}<|start|>{{ message.role }}<|message|>{{ message.content }}<|end|>{% endfor %}{% if add_generation_prompt %}<|start|>assistant{% endif %}",
13879 "harmony-structured-template",
13880 );
13881 template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
13882 template
13883 }
13884
13885 #[test]
13886 fn harmony_structured_format_activates_at_final_payload() {
13887 let template = harmony_json_template();
13888 for (response_format, constrained) in [
13889 (json!({"type": "json_object"}), true),
13890 (
13891 json!({
13892 "type": "json_schema",
13893 "json_schema": {
13894 "name": "answer",
13895 "strict": true,
13896 "schema": {
13897 "type": "object",
13898 "properties": {"answer": {"type": "integer"}},
13899 "required": ["answer"],
13900 "additionalProperties": false
13901 }
13902 }
13903 }),
13904 true,
13905 ),
13906 (
13907 json!({
13908 "type": "json_schema",
13909 "json_schema": {
13910 "name": "best_effort",
13911 "strict": false,
13912 "schema": {"type": "object"}
13913 }
13914 }),
13915 false,
13916 ),
13917 (json!({"type": "text"}), false),
13918 ] {
13919 let request = chat_request(json!({"response_format": response_format}));
13920 let internal =
13921 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13922 .unwrap();
13923 assert_eq!(
13924 internal.sampling_params.structured_output_start,
13925 if constrained {
13926 StructuredOutputStart::HarmonyFinal
13927 } else {
13928 StructuredOutputStart::Immediate
13929 }
13930 );
13931 assert_eq!(
13932 internal.sampling_params.response_completion_boundary,
13933 ResponseCompletionBoundary::Immediate,
13934 "Harmony framing must not be gated on a Text reasoning delimiter"
13935 );
13936 internal.sampling_params.validate().unwrap();
13937 }
13938 }
13939
13940 fn harmony_json_request(stream: bool) -> Value {
13941 json!({
13942 "model": "stub-model",
13943 "messages": [{"role": "user", "content": "Return an answer object."}],
13944 "stream": stream,
13945 "response_format": {
13946 "type": "json_schema",
13947 "json_schema": {
13948 "name": "answer",
13949 "strict": true,
13950 "schema": {
13951 "type": "object",
13952 "properties": {"answer": {"type": "integer"}},
13953 "required": ["answer"],
13954 "additionalProperties": false
13955 }
13956 }
13957 }
13958 })
13959 }
13960
13961 #[tokio::test]
13962 async fn harmony_strict_json_routes_validate_final_payload_in_sync_and_sse() {
13963 for (chunks, finish_reason, reasoning) in [
13964 (
13965 vec![
13966 "<|channel|>fi",
13967 "nal<|message|>{\"answer\":",
13968 "42}<|return|>",
13969 ],
13970 FinishReason::EOS,
13971 "",
13972 ),
13973 (
13974 vec![
13975 "<|channel|>analysis<|message|>Compute.",
13976 "<|end|><|start|>assistant<|channel|>fi",
13977 "nal<|message|>{\"answer\":42}<|return|>",
13978 ],
13979 FinishReason::EOS,
13980 "Compute.",
13981 ),
13982 (
13983 vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
13984 FinishReason::Length,
13985 "",
13986 ),
13987 (
13988 vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
13989 FinishReason::Stop,
13990 "",
13991 ),
13992 ] {
13993 for stream in [false, true] {
13994 let engine = StubLlm {
13995 finish_reason,
13996 ..StubLlm::with_stream_chunks(&chunks)
13997 };
13998 let router = AxumServer::from_llm(Arc::new(engine))
13999 .with_prompt_template(Some(harmony_json_template()))
14000 .build_router();
14001 let mut request = harmony_json_request(stream);
14002 if finish_reason == FinishReason::Stop {
14003 request["stop"] = json!(["<|return|>"]);
14006 }
14007 let response = post_json(router, "/v1/chat/completions", request).await;
14008 assert_eq!(response.status(), AxumStatusCode::OK);
14009 if stream {
14010 let body = response_text(response).await;
14011 assert!(body.contains("data: [DONE]"));
14012 let events = responses_sse_json_events(&body);
14013 assert!(events.iter().all(|event| event.get("error").is_none()));
14014 let content: String = events
14015 .iter()
14016 .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
14017 .collect();
14018 let actual_reasoning: String = events
14019 .iter()
14020 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
14021 .collect();
14022 assert_eq!(
14023 serde_json::from_str::<Value>(&content).unwrap(),
14024 json!({"answer": 42})
14025 );
14026 assert_eq!(actual_reasoning, reasoning);
14027 } else {
14028 let body = response_json(response).await;
14029 let message = &body["choices"][0]["message"];
14030 assert_eq!(message["content"], "{\"answer\":42}");
14031 assert_eq!(message["reasoning"].as_str().unwrap_or(""), reasoning);
14032 }
14033 }
14034 }
14035 }
14036
14037 #[tokio::test]
14038 async fn harmony_strict_json_routes_reject_bad_framing_and_payload_without_sse_leaks() {
14039 for output in [
14040 "{\"answer\":42}",
14041 "<|channel|>final<|message|>{\"answer\":42}",
14042 "<|channel|>final<|message|>{\"answer\":42}<|call|>",
14043 "<|channel|>analysis<|message|>Compute.<|end|>\
14044 <|start|>assistant<|channel|>final<|message|>{\"answer\":\"wrong\"}<|return|>",
14045 ] {
14046 for stream in [false, true] {
14047 let response = post_json(
14048 router_with_stub_and_template(output, harmony_json_template()),
14049 "/v1/chat/completions",
14050 harmony_json_request(stream),
14051 )
14052 .await;
14053 if stream {
14054 assert_eq!(response.status(), AxumStatusCode::OK);
14055 let body = response_text(response).await;
14056 assert!(body.contains("data: [DONE]"));
14057 let events = responses_sse_json_events(&body);
14058 assert!(events.iter().any(|event| event.get("error").is_some()));
14059 for event in events {
14060 for field in ["content", "reasoning"] {
14061 assert!(event["choices"][0]["delta"][field]
14062 .as_str()
14063 .unwrap_or("")
14064 .is_empty());
14065 }
14066 }
14067 } else {
14068 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
14069 let body = response_json(response).await;
14070 assert_eq!(body["error"]["type"], "internal_server_error");
14071 assert!(body.get("choices").is_none());
14072 }
14073 }
14074 }
14075 }
14076
14077 #[test]
14078 fn json_object_thinking_template_activates_after_typed_end_delimiter() {
14079 let request = chat_request(json!({
14080 "response_format": {"type": "json_object"}
14081 }));
14082 let template = ModelChatTemplate::new(
14083 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14084 "thinking-test-template",
14085 );
14086
14087 assert_eq!(
14088 template.reasoning_protocol,
14089 ModelReasoningProtocol::PromptOpened
14090 );
14091 let internal =
14092 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14093 .expect("convert thinking json_object");
14094
14095 assert!(internal.prompt.ends_with("<assistant><think>\n"));
14096 assert!(internal
14097 .prompt
14098 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
14099 assert!(
14100 !internal.prompt.contains(THINK_END_TAG),
14101 "the instruction must not echo the typed end delimiter: {}",
14102 internal.prompt
14103 );
14104 assert_eq!(
14105 internal.sampling_params.structured_output_start,
14106 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14107 );
14108 assert_eq!(
14109 internal.sampling_params.response_completion_boundary,
14110 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14111 delimiter: THINK_END_TAG.to_string(),
14112 alternate_envelope: None,
14113 }
14114 );
14115 }
14116
14117 #[test]
14118 fn json_object_model_generated_thinking_activates_after_typed_end_delimiter() {
14119 let request = chat_request(json!({
14120 "response_format": {"type": "json_object"},
14121 "chat_template_kwargs": {"enable_thinking": true}
14122 }));
14123 let template = ModelChatTemplate::new(
14124 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
14125 "qwen3-model-generated-thinking-template",
14126 );
14127
14128 let internal =
14129 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14130 .expect("convert model-generated thinking json_object");
14131
14132 assert!(!has_unclosed_thinking_block(&internal.prompt));
14133 assert!(internal.prompt.ends_with("<assistant>"));
14134 assert!(internal
14135 .prompt
14136 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
14137 assert!(
14138 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
14139 "the instruction must not teach the model the typed reasoning delimiter: {}",
14140 internal.prompt
14141 );
14142 assert_eq!(
14143 internal.sampling_params.structured_output_start,
14144 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14145 );
14146 assert_eq!(
14147 internal.sampling_params.response_completion_boundary,
14148 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14149 delimiter: THINK_END_TAG.to_string(),
14150 alternate_envelope: None,
14151 }
14152 );
14153 }
14154
14155 #[test]
14156 fn strict_schema_model_generated_thinking_does_not_echo_typed_delimiter() {
14157 let request = chat_request(json!({
14158 "response_format": {
14159 "type": "json_schema",
14160 "json_schema": {
14161 "name": "reasoning_result",
14162 "strict": true,
14163 "schema": {
14164 "type": "object",
14165 "properties": {
14166 "result": {"type": "string", "const": "G00-c21-schema-OK"}
14167 },
14168 "required": ["result"],
14169 "additionalProperties": false
14170 }
14171 }
14172 },
14173 "chat_template_kwargs": {"enable_thinking": true}
14174 }));
14175 let template = ModelChatTemplate::new(
14176 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
14177 "qwen3-model-generated-thinking-template",
14178 );
14179
14180 let internal =
14181 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14182 .expect("convert model-generated thinking strict schema");
14183
14184 assert!(!has_unclosed_thinking_block(&internal.prompt));
14185 assert!(internal.prompt.ends_with("<assistant>"));
14186 assert!(internal.prompt.contains("G00-c21-schema-OK"));
14187 assert!(
14188 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
14189 "the instruction must not teach the model the typed reasoning delimiter: {}",
14190 internal.prompt
14191 );
14192 assert_eq!(
14193 internal.sampling_params.structured_output_start,
14194 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14195 );
14196 assert_eq!(
14197 internal.sampling_params.response_completion_boundary,
14198 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14199 delimiter: THINK_END_TAG.to_string(),
14200 alternate_envelope: None,
14201 }
14202 );
14203 }
14204
14205 #[test]
14206 fn json_object_model_generated_thinking_hard_off_starts_immediately() {
14207 let request = chat_request(json!({
14208 "response_format": {"type": "json_object"},
14209 "chat_template_kwargs": {"enable_thinking": false}
14210 }));
14211 let template = ModelChatTemplate::new(
14212 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
14213 "qwen3-model-generated-thinking-template",
14214 );
14215
14216 let internal =
14217 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14218 .expect("convert disabled model-generated thinking json_object");
14219
14220 assert_eq!(
14221 internal.sampling_params.structured_output_start,
14222 StructuredOutputStart::Immediate
14223 );
14224 assert_eq!(
14225 internal.sampling_params.response_completion_boundary,
14226 ResponseCompletionBoundary::Immediate
14227 );
14228 assert!(internal.prompt.contains("no chain-of-thought"));
14229 }
14230
14231 #[test]
14232 fn response_completion_contract_is_set_on_text_thinking_template() {
14233 let request = chat_request(json!({}));
14234 let template = ModelChatTemplate::new(
14235 "{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14236 "thinking-test-template",
14237 );
14238
14239 let internal =
14240 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14241 .expect("convert thinking text request");
14242
14243 assert_eq!(
14244 internal.sampling_params.structured_output_start,
14245 StructuredOutputStart::Immediate
14246 );
14247 assert_eq!(
14248 internal.sampling_params.response_completion_boundary,
14249 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14250 delimiter: THINK_END_TAG.to_string(),
14251 alternate_envelope: None,
14252 }
14253 );
14254 }
14255
14256 #[test]
14257 fn thinking_tool_request_compiles_typed_envelope_into_completion_contract() {
14258 let request = chat_request(json!({
14259 "tools": [{
14260 "type": "function",
14261 "function": {
14262 "name": "weather",
14263 "parameters": {
14264 "type": "object",
14265 "properties": {"city": {"type": "string"}},
14266 "required": ["city"]
14267 }
14268 }
14269 }]
14270 }));
14271 let template = ModelChatTemplate::new(
14272 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14273 "thinking-tool-template",
14274 );
14275
14276 let internal =
14277 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14278 .expect("convert thinking tool request");
14279
14280 assert_eq!(
14281 internal.sampling_params.response_completion_boundary,
14282 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14283 delimiter: THINK_END_TAG.to_string(),
14284 alternate_envelope: Some(ferrum_types::ResponseCompletionEnvelope {
14285 open_token_text: "<tool_call>".to_string(),
14286 close_token_text: "</tool_call>".to_string(),
14287 max_envelopes: 32,
14288 }),
14289 }
14290 );
14291 }
14292
14293 #[test]
14294 fn strict_json_schema_response_format_uses_guided_sampling_mode() {
14295 let request = chat_request(json!({
14296 "response_format": {
14297 "type": "json_schema",
14298 "json_schema": {
14299 "name": "answer",
14300 "strict": true,
14301 "schema": {
14302 "type": "object",
14303 "properties": {"answer": {"type": "string"}},
14304 "required": ["answer"]
14305 }
14306 }
14307 }
14308 }));
14309
14310 let internal = convert_chat_request(&request).expect("convert strict json_schema");
14311 assert!(
14312 internal
14313 .prompt
14314 .contains("response_format requires a single valid JSON value"),
14315 "response_format instruction should reach the model prompt: {}",
14316 internal.prompt
14317 );
14318 let ferrum_types::ResponseFormat::JsonSchema(schema) =
14319 internal.sampling_params.response_format
14320 else {
14321 panic!(
14322 "strict json_schema must reach guided decoding, got {:?}",
14323 internal.sampling_params.response_format
14324 );
14325 };
14326 let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
14327 assert_eq!(schema["type"], "object");
14328 assert_eq!(schema["properties"]["answer"]["type"], "string");
14329 assert_eq!(schema["required"], json!(["answer"]));
14330 }
14331
14332 #[tokio::test]
14333 async fn strict_json_schema_validates_non_streaming_response() {
14334 let request = chat_request(json!({
14335 "response_format": {
14336 "type": "json_schema",
14337 "json_schema": {
14338 "name": "answer",
14339 "strict": true,
14340 "schema": {
14341 "type": "object",
14342 "properties": {"answer": {"type": "string"}},
14343 "required": ["answer"]
14344 }
14345 }
14346 }
14347 }));
14348 let response = chat_completions_handler(
14349 State(state_with_stub("{\"answer\":\"yes\"}")),
14350 HeaderMap::new(),
14351 Ok(Json(request)),
14352 )
14353 .await
14354 .expect("strict response");
14355 assert_eq!(response.status(), AxumStatusCode::OK);
14356 let body = response_json(response).await;
14357 assert_eq!(
14358 body["choices"][0]["message"]["content"],
14359 "{\"answer\":\"yes\"}"
14360 );
14361 }
14362
14363 #[tokio::test]
14364 async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
14365 let request = chat_request(json!({
14366 "response_format": {
14367 "type": "json_schema",
14368 "json_schema": {
14369 "name": "answer",
14370 "strict": true,
14371 "schema": {
14372 "type": "object",
14373 "properties": {"answer": {"type": "string"}},
14374 "required": ["answer"]
14375 }
14376 }
14377 }
14378 }));
14379 let response = chat_completions_handler(
14380 State(state_with_stub(
14381 "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
14382 )),
14383 HeaderMap::new(),
14384 Ok(Json(request)),
14385 )
14386 .await
14387 .expect("strict response with reasoning");
14388 assert_eq!(response.status(), AxumStatusCode::OK);
14389 let body = response_json(response).await;
14390 assert_eq!(
14391 body["choices"][0]["message"]["content"],
14392 "{\"answer\":\"yes\"}"
14393 );
14394 assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
14395 }
14396
14397 #[tokio::test]
14398 async fn strict_json_schema_validates_streaming_final_response() {
14399 let response = post_json(
14400 router_with_stub("{\"answer\":\"yes\"}"),
14401 "/v1/chat/completions",
14402 json!({
14403 "model": "stub-model",
14404 "messages": [{"role": "user", "content": "Return an answer object."}],
14405 "stream": true,
14406 "response_format": {
14407 "type": "json_schema",
14408 "json_schema": {
14409 "name": "answer",
14410 "strict": true,
14411 "schema": {
14412 "type": "object",
14413 "properties": {"answer": {"type": "string"}},
14414 "required": ["answer"]
14415 }
14416 }
14417 }
14418 }),
14419 )
14420 .await;
14421 assert_eq!(response.status(), AxumStatusCode::OK);
14422 let body = response_text(response).await;
14423 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14424 assert!(
14425 body.contains("\\\"answer\\\":\\\"yes\\\""),
14426 "strict streaming content missing: {body}"
14427 );
14428 assert!(
14429 !body.contains("\"error\""),
14430 "valid strict streaming response should not emit error: {body}"
14431 );
14432 }
14433
14434 #[tokio::test]
14435 async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
14436 let response = post_json(
14437 router_with_stub_stream_chunks(&[
14438 "<think>\nreasoning",
14439 "\n</think>\n\n",
14440 "{\"answer\":\"yes\"}",
14441 ]),
14442 "/v1/chat/completions",
14443 json!({
14444 "model": "stub-model",
14445 "messages": [{"role": "user", "content": "Return an answer object."}],
14446 "stream": true,
14447 "response_format": {
14448 "type": "json_schema",
14449 "json_schema": {
14450 "name": "answer",
14451 "strict": true,
14452 "schema": {
14453 "type": "object",
14454 "properties": {"answer": {"type": "string"}},
14455 "required": ["answer"]
14456 }
14457 }
14458 }
14459 }),
14460 )
14461 .await;
14462 assert_eq!(response.status(), AxumStatusCode::OK);
14463 let body = response_text(response).await;
14464 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14465 assert!(
14466 body.contains("\\\"answer\\\":\\\"yes\\\""),
14467 "strict streaming content missing: {body}"
14468 );
14469 assert!(
14470 body.contains(r#""reasoning":"\nreasoning\n""#),
14471 "strict streaming should keep reasoning separate: {body}"
14472 );
14473 assert!(
14474 !body.contains("\"error\""),
14475 "valid strict streaming response should not emit error: {body}"
14476 );
14477 }
14478
14479 #[tokio::test]
14480 async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
14481 let response = post_json(
14482 router_with_stub("not json"),
14483 "/v1/chat/completions",
14484 json!({
14485 "model": "stub-model",
14486 "messages": [{"role": "user", "content": "Return an answer object."}],
14487 "stream": true,
14488 "response_format": {
14489 "type": "json_schema",
14490 "json_schema": {
14491 "name": "answer",
14492 "strict": true,
14493 "schema": {
14494 "type": "object",
14495 "properties": {"answer": {"type": "string"}},
14496 "required": ["answer"]
14497 }
14498 }
14499 }
14500 }),
14501 )
14502 .await;
14503 assert_eq!(response.status(), AxumStatusCode::OK);
14504 let body = response_text(response).await;
14505 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14506 assert!(
14507 body.contains("\"type\":\"internal_server_error\""),
14508 "strict streaming validation failure should emit OpenAI error: {body}"
14509 );
14510 assert!(
14511 body.contains("\"param\":\"response_format.json_schema\""),
14512 "strict streaming validation error should identify schema param: {body}"
14513 );
14514 assert!(
14515 body.contains("invalid JSON"),
14516 "strict streaming validation should report invalid JSON: {body}"
14517 );
14518 assert!(
14519 !body.contains("not json"),
14520 "strict streaming must not emit invalid partial deltas before validation failure: {body}"
14521 );
14522 }
14523
14524 #[tokio::test]
14525 async fn route_strict_json_schema_supported_schema_passes_100_runs() {
14526 let request_body = json!({
14527 "model": "stub-model",
14528 "messages": [{"role": "user", "content": "Return an answer object."}],
14529 "response_format": {
14530 "type": "json_schema",
14531 "json_schema": {
14532 "name": "answer",
14533 "strict": true,
14534 "schema": {
14535 "type": "object",
14536 "properties": {"answer": {"type": "string"}},
14537 "required": ["answer"]
14538 }
14539 }
14540 }
14541 });
14542 let router = router_with_stub("{\"answer\":\"yes\"}");
14543 for run in 0..100 {
14544 let response =
14545 post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
14546 assert_eq!(
14547 response.status(),
14548 AxumStatusCode::OK,
14549 "strict schema run {run} returned non-200"
14550 );
14551 let body = response_json(response).await;
14552 let content = body["choices"][0]["message"]["content"]
14553 .as_str()
14554 .unwrap_or("");
14555 assert_eq!(
14556 content, "{\"answer\":\"yes\"}",
14557 "strict schema run {run} returned unexpected content"
14558 );
14559 let parsed: serde_json::Value =
14560 serde_json::from_str(content).expect("strict content JSON");
14561 assert_eq!(parsed["answer"], "yes");
14562 }
14563 }
14564
14565 #[test]
14566 fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
14567 let cache = CacheRuntimeState::default();
14568 let policy = CachePolicy {
14569 prefix_cache_enabled: true,
14570 session_cache_mode: "memory".to_string(),
14571 session_cache_max_entries: 128,
14572 session_cache_max_tokens: 4096,
14573 };
14574 cache.record_prefix_prompt("alpha beta gamma", &policy);
14575 cache.record_prefix_prompt("alpha beta delta", &policy);
14576
14577 let engine_snapshot = json!({
14578 "position": "real-kv-reuse",
14579 "source": "llama-family-paged-block-prefix-cache",
14580 "enabled": true,
14581 "hits": 7,
14582 "misses": 3,
14583 "evictions": 1,
14584 "saved_prefill_tokens": 64,
14585 "entries": 5,
14586 "bytes": 8192,
14587 "block_size": 16,
14588 "kv_dtype": "fp16",
14589 "selected_pipeline_mode": "batch",
14590 "selected_stage_bridge": "host",
14591 "stage_count": 2,
14592 });
14593
14594 let health = cache.health_json(&policy, Some(&engine_snapshot));
14595 let prefix = &health["prefix_cache"];
14596 assert_eq!(prefix["position"], "real-kv-reuse");
14597 assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
14598 assert_eq!(prefix["hits"], 7);
14599 assert_eq!(prefix["misses"], 3);
14600 assert_eq!(prefix["evictions"], 1);
14601 assert_eq!(prefix["saved_prefill_tokens"], 64);
14602 assert_eq!(prefix["entries"], 5);
14603 assert_eq!(prefix["bytes"], 8192);
14604 assert_eq!(prefix["block_size"], 16);
14605 assert_eq!(prefix["kv_dtype"], "fp16");
14606 assert_eq!(prefix["selected_pipeline_mode"], "batch");
14607 assert_eq!(prefix["selected_stage_bridge"], "host");
14608 assert_eq!(prefix["stage_count"], 2);
14609
14610 let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
14611 assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
14612 assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
14613 assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
14614 assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
14615 assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
14616 }
14617
14618 #[tokio::test]
14619 async fn strict_json_schema_invalid_model_output_fails_before_response() {
14620 let request = chat_request(json!({
14621 "response_format": {
14622 "type": "json_schema",
14623 "json_schema": {
14624 "name": "answer",
14625 "strict": true,
14626 "schema": {
14627 "type": "object",
14628 "properties": {"answer": {"type": "string"}},
14629 "required": ["answer"]
14630 }
14631 }
14632 }
14633 }));
14634 let err = chat_completions_handler(
14635 State(state_with_stub("not json")),
14636 HeaderMap::new(),
14637 Ok(Json(request)),
14638 )
14639 .await
14640 .expect_err("invalid strict response should fail");
14641 let (status, body) = error_json(err).await;
14642 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14643 assert_eq!(body["error"]["type"], "internal_server_error");
14644 assert!(body["error"]["message"]
14645 .as_str()
14646 .unwrap()
14647 .contains("json_schema.strict"));
14648 }
14649
14650 #[tokio::test]
14651 async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
14652 let request = chat_request(json!({
14653 "response_format": {
14654 "type": "json_schema",
14655 "json_schema": {
14656 "name": "answer",
14657 "strict": true,
14658 "schema": {
14659 "type": "object",
14660 "properties": {"answer": {"type": "string"}},
14661 "required": ["answer"]
14662 }
14663 }
14664 }
14665 }));
14666 let err = chat_completions_handler(
14667 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
14668 HeaderMap::new(),
14669 Ok(Json(request)),
14670 )
14671 .await
14672 .expect_err("strict schema should fail fenced JSON instead of repairing it");
14673 let (status, body) = error_json(err).await;
14674 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14675 assert_eq!(body["error"]["type"], "internal_server_error");
14676 assert!(body["error"]["message"]
14677 .as_str()
14678 .unwrap()
14679 .contains("json_schema.strict: invalid JSON"));
14680 }
14681}