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,
34 ExecutionResourceAuthority, FerrumConfigBuilder, FerrumError as Error, FerrumProfileEvent,
35 FinishReason, InferenceExecutionEvidence, InferenceRequest, InferenceResponse, ModelId,
36 ModelOutputProtocol, NativeChatOutputProjector, ParsedReasoningResponse, Priority,
37 ProcessMemoryObservation, ProcessMemorySample, ProcessMemorySampler, ProfileEntrypoint,
38 ProfileError, ProfileEventKind, ProfileStatus, ReplayReference, RequestId,
39 ResolvedFerrumConfig, ResourceAction, ResourceTraceEvent, ResponseCompletionBoundary,
40 RuntimeConfigSnapshot, SamplingParams, StructuredOutputStart, TokenId, TokenUsage,
41 DEFAULT_CHAT_REPETITION_PENALTY, DEFAULT_MAX_TOKENS_METADATA_KEY,
42 OBSERVABILITY_PROFILE_SCHEMA_VERSION, PROMPT_OPENED_REASONING_METADATA_KEY, THINK_END_TAG,
43 THINK_START_TAG,
44};
45use sha2::{Digest, Sha256};
46use std::{
47 collections::{BTreeMap, HashMap},
48 error::Error as StdError,
49 fs,
50 path::{Path, PathBuf},
51 sync::{
52 atomic::{AtomicBool, Ordering},
53 Arc, Mutex, OnceLock,
54 },
55 time::Instant,
56};
57use tokio::sync::{mpsc, Notify};
58use tokio_stream::StreamExt;
59use tower::ServiceBuilder;
60use tower_http::{cors::CorsLayer, trace::TraceLayer};
61use tracing::{debug, error, info, span, warn, Level};
62use uuid::Uuid;
63
64mod responses;
65
66const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
67const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
68const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 4096;
69const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
70const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
71const MAX_CACHED_JSON_SCHEMA_VALIDATORS: usize = 64;
72const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
73 &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
74const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
75static JSON_SCHEMA_VALIDATOR_CACHE: OnceLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
76 OnceLock::new();
77
78pub fn default_chat_sampling_params() -> SamplingParams {
82 SamplingParams {
83 max_tokens: DEFAULT_COMPLETION_MAX_TOKENS as usize,
84 temperature: DEFAULT_SAMPLING_TEMPERATURE,
85 top_p: DEFAULT_SAMPLING_TOP_P,
86 repetition_penalty: DEFAULT_CHAT_REPETITION_PENALTY,
87 ..SamplingParams::default()
88 }
89}
90
91#[derive(Debug, Clone)]
92struct CachePolicy {
93 prefix_cache_enabled: bool,
94 session_cache_mode: String,
95 session_cache_max_entries: usize,
96 session_cache_max_tokens: usize,
97}
98
99impl CachePolicy {
100 fn current() -> Self {
101 Self {
102 prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
103 .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
104 .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
105 .unwrap_or(false),
106 session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
107 .unwrap_or_else(|_| "off".to_string())
108 .to_ascii_lowercase(),
109 session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
110 session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
111 }
112 }
113
114 fn session_memory_enabled(&self) -> bool {
115 self.session_cache_mode == "memory"
116 }
117}
118
119fn env_bool(key: &str) -> Option<bool> {
120 match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
121 "1" | "true" | "yes" | "on" => Some(true),
122 "0" | "false" | "no" | "off" => Some(false),
123 _ => None,
124 }
125}
126
127fn env_usize(key: &str) -> Option<usize> {
128 std::env::var(key).ok()?.parse().ok()
129}
130
131static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
133 std::sync::OnceLock::new();
134
135pub fn init_prometheus_recorder() {
140 PROM_HANDLE.get_or_init(|| {
141 let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
142 let handle = builder
143 .install_recorder()
144 .expect("Failed to install Prometheus recorder");
145 info!("Prometheus metrics recorder installed");
146 handle
147 });
148}
149
150pub struct AxumServer {
156 state: AppState,
157 config: ServerConfig,
158 lifecycle: Arc<AxumServerLifecycle>,
159}
160
161#[derive(Default)]
162struct AxumServerLifecycle {
163 shutdown_requested: AtomicBool,
164 running: AtomicBool,
165 engines_stopped: AtomicBool,
166 shutdown_notify: Notify,
167 stopped_notify: Notify,
168 stop_lock: tokio::sync::Mutex<()>,
169}
170
171impl AxumServerLifecycle {
172 fn request_shutdown(&self) {
173 self.shutdown_requested.store(true, Ordering::Release);
174 self.shutdown_notify.notify_waiters();
175 }
176
177 async fn wait_for_shutdown(&self) {
178 while !self.shutdown_requested.load(Ordering::Acquire) {
179 self.shutdown_notify.notified().await;
180 }
181 }
182
183 async fn wait_until_stopped(&self) {
184 while self.running.load(Ordering::Acquire) {
185 self.stopped_notify.notified().await;
186 }
187 }
188}
189
190struct AxumServerRunGuard {
191 lifecycle: Arc<AxumServerLifecycle>,
192}
193
194impl Drop for AxumServerRunGuard {
195 fn drop(&mut self) {
196 self.lifecycle.running.store(false, Ordering::Release);
197 self.lifecycle.stopped_notify.notify_waiters();
198 }
199}
200
201fn single_model_registry(engine_model_id: ModelId, kind: ServedModelKind) -> ServedModelRegistry {
202 let public_name = engine_model_id.to_string();
203 ServedModelRegistry::try_new(engine_model_id, kind, vec![public_name], vec![])
204 .expect("engine config must contain a valid model id")
205}
206
207impl AxumServer {
208 pub fn from_state(state: AppState) -> Self {
210 Self {
211 state,
212 config: ServerConfig::default(),
213 lifecycle: Arc::new(AxumServerLifecycle::default()),
214 }
215 }
216
217 pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
219 Self::from_state(AppState::default().with_llm(engine))
220 }
221
222 pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
224 Self::from_state(AppState::default().with_embed(engine))
225 }
226
227 pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
230 Self::from_state(AppState::default().with_transcribe(engine))
231 }
232
233 pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
235 Self::from_state(AppState::default().with_tts(engine))
236 }
237
238 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
242 self.state = self.state.with_auto_config(auto_config);
243 self
244 }
245
246 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
249 self.state = self.state.with_prompt_template(prompt_template);
250 self
251 }
252
253 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
256 self.state = self.state.with_default_enable_thinking(enable_thinking);
257 self
258 }
259
260 pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
263 self.state = self.state.with_interleaved_system_coalescing(enabled);
264 self
265 }
266
267 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
271 self.state = self.state.with_served_model_registry(registry);
272 self
273 }
274
275 pub fn with_lora_adapters(
277 mut self,
278 base_model_id: impl Into<String>,
279 adapters: Vec<LoraAdapterModel>,
280 ) -> ferrum_types::Result<Self> {
281 let base_model_id = base_model_id.into();
282 let registry = if self.state.served_model_registry.is_empty() {
283 ServedModelRegistry::try_new(
284 base_model_id.clone(),
285 ServedModelKind::Llm,
286 vec![base_model_id],
287 adapters,
288 )
289 } else {
290 self.state
291 .served_model_registry
292 .try_with_lora_adapters(&base_model_id, adapters)
293 }
294 .map_err(|error| Error::config(error.to_string()))?;
295 self.state = self.state.with_served_model_registry(registry);
296 Ok(self)
297 }
298
299 async fn shutdown_loaded_engines(&self) -> ferrum_types::Result<()> {
300 let mut first_error = None;
301 if let Some(engine) = &self.state.llm {
302 if let Err(error) = engine.shutdown().await {
303 first_error = Some(error);
304 }
305 }
306 if let Some(engine) = &self.state.embed {
307 if let Err(error) = engine.shutdown().await {
308 if first_error.is_none() {
309 first_error = Some(error);
310 }
311 }
312 }
313 if let Some(engine) = &self.state.transcribe {
314 if let Err(error) = engine.shutdown().await {
315 if first_error.is_none() {
316 first_error = Some(error);
317 }
318 }
319 }
320 if let Some(engine) = &self.state.tts {
321 if let Err(error) = engine.shutdown().await {
322 if first_error.is_none() {
323 first_error = Some(error);
324 }
325 }
326 }
327 first_error.map_or(Ok(()), Err)
328 }
329
330 #[allow(dead_code)]
332 fn build_router(&self) -> Router {
333 self.build_router_with_state(self.state.clone())
334 }
335
336 fn build_router_with_state(&self, app_state: AppState) -> Router {
337 Router::new()
338 .route("/v1/chat/completions", post(chat_completions_handler))
340 .route("/v1/responses", post(responses::responses_handler))
341 .route("/v1/completions", post(completions_handler))
342 .route("/v1/embeddings", post(embeddings_handler))
343 .route("/v1/audio/transcriptions", post(transcriptions_handler))
344 .route("/v1/audio/speech", post(speech_handler))
345 .route("/v1/models", get(models_handler))
346 .route("/health", get(health_handler))
348 .route("/metrics", get(metrics_handler))
349 .route("/", get(root_handler))
350 .layer(
352 ServiceBuilder::new()
353 .layer(TraceLayer::new_for_http())
354 .layer(CorsLayer::permissive()), )
356 .with_state(app_state)
357 }
358}
359
360#[derive(Clone, Default)]
364pub struct AppState {
365 pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
366 pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
367 pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
368 pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
369 pub auto_config: Option<ResolvedFerrumConfig>,
370 pub prompt_template: Option<Arc<ModelChatTemplate>>,
371 pub default_enable_thinking: Option<bool>,
372 interleaved_system_coalescing: Option<bool>,
373 pub served_model_registry: Arc<ServedModelRegistry>,
374 pub request_dump_dir: Option<Arc<PathBuf>>,
375 pub profile_jsonl: Option<Arc<PathBuf>>,
376 pub profile_detail: ferrum_types::ObservabilityProfileDetail,
377 pub memory_profile_jsonl: Option<Arc<PathBuf>>,
378 pub first_request_memory_recorded: Arc<AtomicBool>,
379 cache: Arc<CacheRuntimeState>,
380}
381
382impl AppState {
383 fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
384 if self.llm.as_ref().is_some_and(|engine| {
385 engine.execution_resource_authority() == ExecutionResourceAuthority::PlanRuntime
386 }) {
387 return;
391 }
392 self.cache.record_prefix_prompt(prompt, policy);
393 }
394
395 pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
396 if self.served_model_registry.is_empty() {
397 self.served_model_registry = Arc::new(single_model_registry(
398 engine.config().model.model_id.clone(),
399 ServedModelKind::Llm,
400 ));
401 }
402 self.llm = Some(engine);
403 self
404 }
405 pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
406 if self.served_model_registry.is_empty() {
407 self.served_model_registry = Arc::new(single_model_registry(
408 engine.config().model.model_id.clone(),
409 ServedModelKind::Embedding,
410 ));
411 }
412 self.embed = Some(engine);
413 self
414 }
415 pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
416 if self.served_model_registry.is_empty() {
417 self.served_model_registry = Arc::new(single_model_registry(
418 engine.config().model.model_id.clone(),
419 ServedModelKind::Transcription,
420 ));
421 }
422 self.transcribe = Some(engine);
423 self
424 }
425 pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
426 if self.served_model_registry.is_empty() {
427 self.served_model_registry = Arc::new(single_model_registry(
428 engine.config().model.model_id.clone(),
429 ServedModelKind::Speech,
430 ));
431 }
432 self.tts = Some(engine);
433 self
434 }
435
436 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
437 self.auto_config = Some(auto_config);
438 self
439 }
440
441 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
442 self.prompt_template = prompt_template.map(Arc::new);
443 self
444 }
445
446 pub fn with_default_enable_thinking(mut self, enable_thinking: Option<bool>) -> Self {
447 self.default_enable_thinking = enable_thinking;
448 self
449 }
450
451 pub fn with_interleaved_system_coalescing(mut self, enabled: bool) -> Self {
452 self.interleaved_system_coalescing = Some(enabled);
453 self
454 }
455
456 pub fn with_served_model_registry(mut self, registry: ServedModelRegistry) -> Self {
457 self.served_model_registry = Arc::new(registry);
458 self
459 }
460
461 pub fn with_request_dump_dir(mut self, request_dump_dir: Option<PathBuf>) -> Self {
462 self.request_dump_dir = request_dump_dir.map(Arc::new);
463 self
464 }
465
466 pub fn with_profile_jsonl(mut self, profile_jsonl: Option<PathBuf>) -> Self {
467 self.profile_jsonl = profile_jsonl.map(Arc::new);
468 self
469 }
470
471 pub fn with_profile_detail(
472 mut self,
473 profile_detail: ferrum_types::ObservabilityProfileDetail,
474 ) -> Self {
475 self.profile_detail = profile_detail;
476 self
477 }
478
479 pub fn with_memory_profile_jsonl(mut self, memory_profile_jsonl: Option<PathBuf>) -> Self {
480 self.memory_profile_jsonl = memory_profile_jsonl.map(Arc::new);
481 self
482 }
483
484 async fn status(&self) -> EngineStatus {
487 if let Some(e) = &self.llm {
488 return e.status().await;
489 }
490 if let Some(e) = &self.embed {
491 return e.status().await;
492 }
493 if let Some(e) = &self.transcribe {
494 return e.status().await;
495 }
496 if let Some(e) = &self.tts {
497 return e.status().await;
498 }
499 EngineStatus {
500 is_ready: false,
501 loaded_models: vec![],
502 active_requests: 0,
503 queued_requests: 0,
504 memory_usage: ferrum_types::MemoryUsage {
505 total_bytes: 0,
506 used_bytes: 0,
507 free_bytes: 0,
508 gpu_memory_bytes: None,
509 cpu_memory_bytes: None,
510 cache_memory_bytes: 0,
511 utilization_percent: 0.0,
512 },
513 uptime_seconds: 0,
514 last_heartbeat: chrono::Utc::now(),
515 version: env!("CARGO_PKG_VERSION").to_string(),
516 }
517 }
518
519 fn metrics(&self) -> EngineMetrics {
520 if let Some(e) = &self.llm {
521 return e.metrics();
522 }
523 if let Some(e) = &self.embed {
524 return e.metrics();
525 }
526 if let Some(e) = &self.transcribe {
527 return e.metrics();
528 }
529 if let Some(e) = &self.tts {
530 return e.metrics();
531 }
532 EngineMetrics {
533 total_requests: 0,
534 successful_requests: 0,
535 failed_requests: 0,
536 avg_request_latency_ms: 0.0,
537 p95_request_latency_ms: 0.0,
538 p99_request_latency_ms: 0.0,
539 throughput_rps: 0.0,
540 tokens_per_second: 0.0,
541 queue_metrics: Default::default(),
542 resource_utilization: Default::default(),
543 error_stats: Default::default(),
544 performance_breakdown: Default::default(),
545 }
546 }
547}
548
549#[derive(Default)]
550struct CacheRuntimeState {
551 stats: Mutex<CacheStats>,
552 prefix_prompts: Mutex<HashMap<String, usize>>,
553 sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
554}
555
556#[derive(Debug, Clone, Default)]
557struct CacheStats {
558 prefix_hits: u64,
559 prefix_misses: u64,
560 prefix_evictions: u64,
561 prefix_saved_prefill_tokens: u64,
562 prefix_entries: u64,
563 prefix_bytes: u64,
564 session_hits: u64,
565 session_misses: u64,
566 session_evictions: u64,
567 session_entries: u64,
568 session_tokens: u64,
569}
570
571#[derive(Clone)]
572struct SessionContext {
573 id: String,
574 prior_messages: Vec<ChatMessage>,
575 incoming_messages: Vec<ChatMessage>,
576}
577
578impl CacheRuntimeState {
579 fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
580 if !policy.prefix_cache_enabled {
581 return;
582 }
583
584 let prompt_tokens = approx_tokens(prompt);
585 let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
586 let saved_tokens = prompts
587 .keys()
588 .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
589 .max()
590 .unwrap_or(0);
591
592 let mut stats = self.stats.lock().expect("cache stats lock");
593 if saved_tokens > 0 {
594 stats.prefix_hits += 1;
595 stats.prefix_saved_prefill_tokens += saved_tokens as u64;
596 } else {
597 stats.prefix_misses += 1;
598 }
599
600 let max_entries = policy.session_cache_max_entries.max(1);
601 if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
602 if let Some(key) = prompts.keys().next().cloned() {
603 prompts.remove(&key);
604 stats.prefix_evictions += 1;
605 }
606 }
607 prompts.insert(prompt.to_string(), prompt_tokens);
608 stats.prefix_entries = prompts.len() as u64;
609 stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
610 }
611
612 fn prepare_session_request(
613 &self,
614 request: &mut ChatCompletionsRequest,
615 headers: &HeaderMap,
616 policy: &CachePolicy,
617 ) -> Option<SessionContext> {
618 let session_id = request_session_id(headers, request)?;
619 if !policy.session_memory_enabled() {
620 return None;
621 }
622
623 let incoming_messages = request.messages.clone();
624 let prior_messages = {
625 let sessions = self.sessions.lock().expect("session cache lock");
626 sessions.get(&session_id).cloned().unwrap_or_default()
627 };
628 {
629 let mut stats = self.stats.lock().expect("cache stats lock");
630 if prior_messages.is_empty() {
631 stats.session_misses += 1;
632 } else {
633 stats.session_hits += 1;
634 let mut merged = prior_messages.clone();
635 merged.extend(request.messages.clone());
636 request.messages = merged;
637 }
638 }
639
640 Some(SessionContext {
641 id: session_id,
642 prior_messages,
643 incoming_messages,
644 })
645 }
646
647 fn update_session(
648 &self,
649 context: Option<SessionContext>,
650 assistant_message: ChatMessage,
651 policy: &CachePolicy,
652 ) {
653 let Some(context) = context else {
654 return;
655 };
656 if !policy.session_memory_enabled() {
657 return;
658 }
659
660 let mut history = context.prior_messages;
661 history.extend(context.incoming_messages);
662 history.push(assistant_message);
663 trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
664
665 let mut sessions = self.sessions.lock().expect("session cache lock");
666 if !sessions.contains_key(&context.id)
667 && sessions.len() >= policy.session_cache_max_entries.max(1)
668 {
669 if let Some(evict_key) = sessions.keys().next().cloned() {
670 sessions.remove(&evict_key);
671 self.stats
672 .lock()
673 .expect("cache stats lock")
674 .session_evictions += 1;
675 }
676 }
677 sessions.insert(context.id, history);
678
679 let entries = sessions.len() as u64;
680 let tokens = sessions
681 .values()
682 .map(|messages| {
683 messages
684 .iter()
685 .map(|msg| approx_tokens(&msg.content))
686 .sum::<usize>()
687 })
688 .sum::<usize>() as u64;
689 let mut stats = self.stats.lock().expect("cache stats lock");
690 stats.session_entries = entries;
691 stats.session_tokens = tokens;
692 }
693
694 fn stats(&self) -> CacheStats {
695 let mut stats = self.stats.lock().expect("cache stats lock").clone();
696 stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
697 let sessions = self.sessions.lock().expect("session cache lock");
698 stats.session_entries = sessions.len() as u64;
699 stats.session_tokens = sessions
700 .values()
701 .map(|messages| {
702 messages
703 .iter()
704 .map(|msg| approx_tokens(&msg.content))
705 .sum::<usize>()
706 })
707 .sum::<usize>() as u64;
708 stats
709 }
710
711 fn health_json(
712 &self,
713 policy: &CachePolicy,
714 engine_prefix_cache: Option<&serde_json::Value>,
715 ) -> serde_json::Value {
716 let stats = self.stats();
717 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
718 let prefix_misses =
719 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
720 let prefix_evictions =
721 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
722 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
723 .unwrap_or(stats.prefix_saved_prefill_tokens);
724 let prefix_entries =
725 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
726 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
727 let mut prefix_cache = serde_json::json!({
728 "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
729 "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
730 "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
731 "entries": prefix_entries,
732 "hits": prefix_hits,
733 "misses": prefix_misses,
734 "evictions": prefix_evictions,
735 "saved_prefill_tokens": prefix_saved,
736 "bytes": prefix_bytes,
737 "block_size": engine_u64(engine_prefix_cache, "block_size"),
738 "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
739 });
740 if let (Some(engine), Some(prefix)) = (
741 engine_prefix_cache.and_then(|value| value.as_object()),
742 prefix_cache.as_object_mut(),
743 ) {
744 for (key, value) in engine {
745 prefix.entry(key.clone()).or_insert_with(|| value.clone());
746 }
747 }
748 serde_json::json!({
749 "prefix_cache": prefix_cache,
750 "session_cache": {
751 "mode": policy.session_cache_mode,
752 "entries": stats.session_entries,
753 "hits": stats.session_hits,
754 "misses": stats.session_misses,
755 "evictions": stats.session_evictions,
756 "tokens": stats.session_tokens,
757 "max_entries": policy.session_cache_max_entries,
758 "max_tokens": policy.session_cache_max_tokens,
759 }
760 })
761 }
762
763 fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
764 let stats = self.stats();
765 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
766 let prefix_misses =
767 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
768 let prefix_evictions =
769 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
770 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
771 .unwrap_or(stats.prefix_saved_prefill_tokens);
772 let prefix_entries =
773 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
774 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
775 format!(
776 concat!(
777 "ferrum_prefix_cache_hits_total {}\n",
778 "ferrum_prefix_cache_misses_total {}\n",
779 "ferrum_prefix_cache_evictions_total {}\n",
780 "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
781 "ferrum_prefix_cache_entries {}\n",
782 "ferrum_prefix_cache_bytes {}\n",
783 "ferrum_session_cache_hits_total {}\n",
784 "ferrum_session_cache_misses_total {}\n",
785 "ferrum_session_cache_evictions_total {}\n",
786 "ferrum_session_cache_entries {}\n",
787 "ferrum_session_cache_tokens {}\n"
788 ),
789 prefix_hits,
790 prefix_misses,
791 prefix_evictions,
792 prefix_saved,
793 prefix_entries,
794 prefix_bytes,
795 stats.session_hits,
796 stats.session_misses,
797 stats.session_evictions,
798 stats.session_entries,
799 stats.session_tokens,
800 )
801 }
802}
803
804fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
805 snapshot?.get(key)?.as_u64()
806}
807
808fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
809 snapshot?.get(key)?.as_bool()
810}
811
812fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
813 snapshot?.get(key)?.as_str()
814}
815
816fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
817 match auto_config {
818 Some(auto_config) => auto_config.effective_config_document(),
819 None => {
820 match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
821 Ok(auto_config) => auto_config.effective_config_document(),
822 Err(err) => serde_json::json!({
823 "schema_version": 1,
824 "error": err.to_string(),
825 }),
826 }
827 }
828 }
829}
830
831fn admission_health_json(
832 engine_status: &EngineStatus,
833 scheduler_metrics: &EngineMetrics,
834 auto_config: &serde_json::Value,
835 runtime_snapshot: Option<&ferrum_types::ExecutorAdmissionSnapshot>,
836 runtime_error: Option<&str>,
837) -> serde_json::Value {
838 let configured = auto_config
839 .get("admission")
840 .and_then(|value| value.as_object());
841 let preflight_effective_max_concurrent = configured
842 .and_then(|value| value.get("effective_max_concurrent"))
843 .and_then(|value| value.as_u64());
844 let effective_max_concurrent = if runtime_error.is_some() {
845 None
846 } else {
847 Some(
848 runtime_snapshot
849 .map(|snapshot| u64::from(snapshot.maximum_active_sequences()))
850 .or(preflight_effective_max_concurrent)
851 .unwrap_or_else(|| {
852 (engine_status.active_requests + engine_status.queued_requests)
853 .max(1)
854 .try_into()
855 .unwrap_or(u64::MAX)
856 }),
857 )
858 };
859 let active_sequences = runtime_error.is_none().then(|| {
860 runtime_snapshot
861 .map(|snapshot| u64::from(snapshot.active_sequences()))
862 .unwrap_or_else(|| engine_status.active_requests as u64)
863 });
864 let waiting_requests = runtime_error.is_none().then(|| {
865 runtime_snapshot
866 .map(|snapshot| u64::from(snapshot.waiting_requests()))
867 .unwrap_or_else(|| engine_status.queued_requests as u64)
868 });
869 serde_json::json!({
870 "schema_version": 2,
871 "source": if runtime_error.is_some() {
872 "runtime_error"
873 } else if runtime_snapshot.is_some() {
874 "runtime_executor"
875 } else {
876 "startup_preflight_and_engine_status"
877 },
878 "runtime_snapshot_available": runtime_snapshot.is_some(),
879 "runtime_contract_error": runtime_error,
880 "resource_authority": runtime_snapshot
881 .and_then(|snapshot| serde_json::to_value(snapshot.resource_authority()).ok())
882 .unwrap_or(serde_json::Value::Null),
883 "effective_max_concurrent": effective_max_concurrent,
884 "maximum_active_sequences": runtime_snapshot
885 .map(|snapshot| u64::from(snapshot.maximum_active_sequences())),
886 "maximum_scheduled_tokens": runtime_snapshot
887 .map(|snapshot| snapshot.maximum_scheduled_tokens()),
888 "preflight_effective_max_concurrent": preflight_effective_max_concurrent,
889 "queue_depth": waiting_requests,
890 "active_sequences": active_sequences,
891 "active_prefill": runtime_snapshot
892 .map(|snapshot| u64::from(snapshot.active_prefill_sequences())),
893 "active_decode": runtime_snapshot
894 .map(|snapshot| u64::from(snapshot.active_decode_sequences())),
895 "current_batch_size": runtime_snapshot
896 .and_then(|snapshot| snapshot.current_batch_size())
897 .map(u64::from),
898 "capacity_blocked_requests": runtime_snapshot
899 .and_then(|snapshot| snapshot.capacity_blocked_requests())
900 .map(u64::from),
901 "rejected_requests_total": 0u64,
902 "failed_requests_total": scheduler_metrics.failed_requests,
903 "completed_requests_total": scheduler_metrics.successful_requests,
904 "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
905 "scheduler_policy": configured
906 .and_then(|value| value.get("scheduler_policy"))
907 .and_then(|value| value.as_str())
908 .unwrap_or("unknown"),
909 "phase_detail_source": if runtime_snapshot.is_some() {
910 "scheduler_request_index_single_read"
911 } else {
912 "unavailable"
913 },
914 })
915}
916
917fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
918 let snapshot_available = u8::from(
919 admission
920 .get("runtime_snapshot_available")
921 .and_then(serde_json::Value::as_bool)
922 .unwrap_or(false),
923 );
924 let mut output = format!("ferrum_admission_runtime_snapshot_available {snapshot_available}\n");
925 for (field, metric) in [
926 (
927 "effective_max_concurrent",
928 "ferrum_admission_effective_max_concurrent",
929 ),
930 (
931 "maximum_active_sequences",
932 "ferrum_admission_maximum_active_sequences",
933 ),
934 (
935 "maximum_scheduled_tokens",
936 "ferrum_admission_maximum_scheduled_tokens",
937 ),
938 ("queue_depth", "ferrum_admission_queue_depth"),
939 (
940 "capacity_blocked_requests",
941 "ferrum_admission_capacity_blocked_requests",
942 ),
943 ("active_sequences", "ferrum_admission_active_sequences"),
944 ("active_prefill", "ferrum_admission_active_prefill"),
945 ("active_decode", "ferrum_admission_active_decode"),
946 ("current_batch_size", "ferrum_admission_current_batch_size"),
947 (
948 "rejected_requests_total",
949 "ferrum_admission_rejected_requests_total",
950 ),
951 (
952 "failed_requests_total",
953 "ferrum_admission_failed_requests_total",
954 ),
955 (
956 "completed_requests_total",
957 "ferrum_admission_completed_requests_total",
958 ),
959 ] {
960 if let Some(value) = admission.get(field).and_then(serde_json::Value::as_u64) {
961 output.push_str(&format!("{metric} {value}\n"));
962 }
963 }
964 output
965}
966
967fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
968 headers
969 .get(FERRUM_SESSION_HEADER)
970 .and_then(|value| value.to_str().ok())
971 .map(str::trim)
972 .filter(|value| !value.is_empty())
973 .map(str::to_string)
974 .or_else(|| {
975 request
976 .metadata
977 .as_ref()
978 .and_then(|metadata| metadata.get("ferrum_session_id"))
979 .and_then(|value| value.as_str())
980 .map(str::trim)
981 .filter(|value| !value.is_empty())
982 .map(str::to_string)
983 })
984}
985
986fn benchmark_request_correlation(
987 headers: &HeaderMap,
988) -> std::result::Result<Option<BenchmarkRequestCorrelation>, ServerError> {
989 let header_value = |name: &'static str| {
990 headers
991 .get(name)
992 .map(|value| {
993 value.to_str().map_err(|_| {
994 ServerError::invalid_request(
995 format!("{name} must contain visible ASCII text"),
996 Some(name),
997 )
998 })
999 })
1000 .transpose()
1001 };
1002 BenchmarkRequestCorrelation::from_header_values(
1003 header_value(BENCHMARK_RUN_ID_HEADER)?,
1004 header_value(BENCHMARK_CELL_ID_HEADER)?,
1005 header_value(BENCHMARK_REPEAT_INDEX_HEADER)?,
1006 header_value(BENCHMARK_PHASE_HEADER)?,
1007 header_value(BENCHMARK_REQUEST_INDEX_HEADER)?,
1008 )
1009 .map_err(|error| ServerError::invalid_request(error, Some(BENCHMARK_RUN_ID_HEADER)))
1010}
1011
1012fn extend_benchmark_profile_attributes(
1013 attributes: &mut BTreeMap<String, serde_json::Value>,
1014 correlation: Option<&BenchmarkRequestCorrelation>,
1015) {
1016 let Some(correlation) = correlation else {
1017 return;
1018 };
1019 attributes.extend([
1020 (
1021 "benchmark_run_id".to_string(),
1022 serde_json::json!(correlation.benchmark_run_id),
1023 ),
1024 (
1025 "cell_id".to_string(),
1026 serde_json::json!(correlation.cell_id),
1027 ),
1028 (
1029 "repeat_index".to_string(),
1030 serde_json::json!(correlation.repeat_index),
1031 ),
1032 (
1033 "phase".to_string(),
1034 serde_json::json!(correlation.phase.as_str()),
1035 ),
1036 (
1037 "request_index".to_string(),
1038 serde_json::json!(correlation.request_index),
1039 ),
1040 ]);
1041}
1042
1043fn approx_tokens(text: &str) -> usize {
1044 approx_tokens_for_chars(text.chars().count())
1045}
1046
1047fn approx_tokens_for_chars(chars: usize) -> usize {
1048 (chars / 4).max(1)
1049}
1050
1051fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
1052 a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
1053}
1054
1055fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
1056 let max_tokens = max_tokens.max(1);
1057 while messages.len() > 1
1058 && messages
1059 .iter()
1060 .map(|msg| approx_tokens(&msg.content))
1061 .sum::<usize>()
1062 > max_tokens
1063 {
1064 messages.remove(0);
1065 }
1066}
1067
1068#[async_trait]
1069impl HttpServer for AxumServer {
1070 async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
1071 if self.lifecycle.shutdown_requested.load(Ordering::Acquire) {
1072 return Err(Error::internal(
1073 "cannot start Axum server after shutdown was requested",
1074 ));
1075 }
1076 self.lifecycle
1077 .running
1078 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1079 .map_err(|_| Error::internal("Axum server is already running"))?;
1080 let _run_guard = AxumServerRunGuard {
1081 lifecycle: Arc::clone(&self.lifecycle),
1082 };
1083 let addr = format!("{}:{}", config.host, config.port);
1084 info!("Starting Axum server on {}", addr);
1085
1086 let app = self.build_router_with_state(
1087 self.state
1088 .clone()
1089 .with_request_dump_dir(config.request_dump_dir.clone())
1090 .with_profile_jsonl(config.profile_jsonl.clone())
1091 .with_profile_detail(config.profile_detail)
1092 .with_memory_profile_jsonl(config.memory_profile_jsonl.clone()),
1093 );
1094 let listener = tokio::net::TcpListener::bind(&addr)
1095 .await
1096 .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
1097
1098 info!("Server listening on {}", addr);
1099
1100 let lifecycle = Arc::clone(&self.lifecycle);
1101 axum::serve(listener, app)
1102 .with_graceful_shutdown(async move { lifecycle.wait_for_shutdown().await })
1103 .await
1104 .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
1105
1106 Ok(())
1107 }
1108
1109 async fn stop(&self, timeout: std::time::Duration) -> ferrum_types::Result<()> {
1110 let _stop_guard = self.lifecycle.stop_lock.lock().await;
1111 info!("Stopping Axum server");
1112 self.lifecycle.request_shutdown();
1113
1114 let mut first_error = None;
1115 if self.lifecycle.running.load(Ordering::Acquire) {
1116 if tokio::time::timeout(timeout, self.lifecycle.wait_until_stopped())
1117 .await
1118 .is_err()
1119 {
1120 first_error = Some(Error::internal(format!(
1121 "Axum server did not drain within {} ms",
1122 timeout.as_millis()
1123 )));
1124 }
1125 }
1126
1127 if !self.lifecycle.engines_stopped.load(Ordering::Acquire) {
1128 match tokio::time::timeout(timeout, self.shutdown_loaded_engines()).await {
1129 Ok(Ok(())) => {
1130 self.lifecycle
1131 .engines_stopped
1132 .store(true, Ordering::Release);
1133 }
1134 Ok(Err(error)) => {
1135 if first_error.is_none() {
1136 first_error = Some(error);
1137 }
1138 }
1139 Err(_) => {
1140 if first_error.is_none() {
1141 first_error = Some(Error::internal(format!(
1142 "engine shutdown did not complete within {} ms",
1143 timeout.as_millis()
1144 )));
1145 }
1146 }
1147 }
1148 }
1149
1150 first_error.map_or(Ok(()), Err)
1151 }
1152
1153 fn is_running(&self) -> bool {
1154 self.lifecycle.running.load(Ordering::Acquire)
1155 }
1156
1157 fn address(&self) -> Option<std::net::SocketAddr> {
1158 format!("{}:{}", self.config.host, self.config.port)
1160 .parse()
1161 .ok()
1162 }
1163
1164 fn register_handler(
1165 &mut self,
1166 _path: &str,
1167 _method: HttpMethod,
1168 _handler: Box<dyn crate::traits::RequestHandler>,
1169 ) {
1170 unimplemented!("Dynamic handler registration not implemented in MVP")
1172 }
1173
1174 fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
1175 unimplemented!("Dynamic middleware registration not implemented in MVP")
1177 }
1178
1179 fn get_metrics(&self) -> ServerMetrics {
1180 ServerMetrics {
1182 total_requests: 0,
1183 requests_by_endpoint: std::collections::HashMap::new(),
1184 requests_by_status: std::collections::HashMap::new(),
1185 avg_response_time_ms: 0.0,
1186 p95_response_time_ms: 0.0,
1187 p99_response_time_ms: 0.0,
1188 active_connections: 0,
1189 bytes_sent: 0,
1190 bytes_received: 0,
1191 error_rate: 0.0,
1192 uptime_seconds: 0,
1193 }
1194 }
1195
1196 async fn health_check(&self) -> HealthStatus {
1197 HealthStatus::Healthy
1198 }
1199}
1200
1201async fn chat_completions_handler(
1203 State(state): State<AppState>,
1204 headers: HeaderMap,
1205 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1206) -> std::result::Result<Response, ServerError> {
1207 chat_completions_handler_with_phases(State(state), headers, request, None).await
1208}
1209
1210async fn chat_completions_handler_with_phases(
1211 State(state): State<AppState>,
1212 headers: HeaderMap,
1213 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
1214 mut message_phases: Option<Vec<Option<AssistantMessagePhase>>>,
1215) -> std::result::Result<Response, ServerError> {
1216 let Json(mut request) = request.map_err(|error| {
1217 ServerError::invalid_request(
1218 format!(
1219 "invalid chat completions request: {}",
1220 json_rejection_detail(&error)
1221 ),
1222 None,
1223 )
1224 })?;
1225 let benchmark_correlation = benchmark_request_correlation(&headers)?;
1226 let cache_policy = CachePolicy::current();
1227 if message_phases
1228 .as_ref()
1229 .is_some_and(|phases| phases.len() != request.messages.len())
1230 {
1231 return Err(ServerError::InternalError(
1232 "Responses message phase metadata did not match input history".to_string(),
1233 ));
1234 }
1235 let session_context =
1236 state
1237 .cache
1238 .prepare_session_request(&mut request, &headers, &cache_policy);
1239 if let Some(phases) = &mut message_phases {
1240 let prepended = request
1241 .messages
1242 .len()
1243 .checked_sub(phases.len())
1244 .ok_or_else(|| {
1245 ServerError::InternalError(
1246 "session preparation shortened Responses input history".to_string(),
1247 )
1248 })?;
1249 phases.splice(0..0, std::iter::repeat(None).take(prepended));
1250 }
1251
1252 let span = span!(Level::INFO, "chat_completions", model = %request.model);
1253 let _enter = span.enter();
1254
1255 info!(
1256 "Received chat completions request for model: {}",
1257 request.model
1258 );
1259 debug!("Request: {:?}", request);
1260
1261 validate_chat_request(&request)?;
1264 let (engine_model_id, lora_adapter) = resolve_request_model(
1265 &state.served_model_registry,
1266 &request.model,
1267 ServedModelKind::Llm,
1268 )?;
1269
1270 let mut inference_request = convert_chat_request_with_template_model_and_default(
1272 &request,
1273 &engine_model_id.0,
1274 state.prompt_template.as_deref(),
1275 state.default_enable_thinking,
1276 state.interleaved_system_coalescing.unwrap_or(true),
1277 message_phases.as_deref(),
1278 )
1279 .map_err(server_error_from_ferrum_error)?;
1280 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
1281 if state.request_dump_dir.is_some() {
1282 inference_request.evidence_request.capture_prompt_token_ids = true;
1283 }
1284 inference_request
1285 .evidence_request
1286 .capture_engine_token_timing = state.profile_detail.captures_engine_token_timing();
1287 state.record_prefix_prompt(&inference_request.prompt, &cache_policy);
1288 if let Err(err) =
1289 write_chat_request_replay_bundle(&state, &headers, &request, &inference_request)
1290 {
1291 warn!("failed to write chat request replay bundle: {}", err);
1292 }
1293
1294 if request.stream.unwrap_or(false) {
1296 handle_chat_completions_stream(state, request, inference_request, benchmark_correlation)
1297 .await
1298 } else {
1299 handle_chat_completions_sync(
1300 state,
1301 request,
1302 inference_request,
1303 session_context,
1304 benchmark_correlation,
1305 )
1306 .await
1307 }
1308}
1309
1310fn json_rejection_detail(rejection: &JsonRejection) -> String {
1311 const MAX_DETAIL_CHARS: usize = 512;
1312
1313 let mut details = Vec::new();
1314 let mut current: Option<&(dyn StdError + 'static)> = Some(rejection);
1315 while let Some(error) = current {
1316 let detail = error.to_string();
1317 if !detail.is_empty() && details.last() != Some(&detail) {
1318 details.push(detail);
1319 }
1320 current = error.source();
1321 }
1322
1323 details.join(": ").chars().take(MAX_DETAIL_CHARS).collect()
1324}
1325
1326fn write_chat_request_replay_bundle(
1327 state: &AppState,
1328 headers: &HeaderMap,
1329 openai_request: &ChatCompletionsRequest,
1330 inference_request: &InferenceRequest,
1331) -> std::result::Result<(), String> {
1332 let Some(root) = state.request_dump_dir.as_ref() else {
1333 return Ok(());
1334 };
1335 let request_id = inference_request.id.to_string();
1336 let bundle_dir = root.join(&request_id);
1337 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1338
1339 let sanitized_body = sanitized_chat_request_body(openai_request);
1340 let replay_body_path = bundle_dir.join("replay_body.json");
1341 write_json_value(&replay_body_path, &sanitized_body)?;
1342 let engine_replay_argv = replay_bundle_argv(&bundle_dir);
1343 let output_text_body = format!(
1344 "[server request replay emitted before response]\nsha256={}\nchars=0\n",
1345 sha256_hex(b"")
1346 );
1347
1348 let request = serde_json::json!({
1349 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1350 "entrypoint": "serve",
1351 "request_id": request_id,
1352 "model": openai_request.model.clone(),
1353 "backend": "actual",
1354 "endpoint": "/v1/chat/completions",
1355 "method": "POST",
1356 "stream": openai_request.stream.unwrap_or(false),
1357 "actual_model_smoke": true,
1358 "sanitized": true,
1359 "http": {
1360 "method": "POST",
1361 "path": "/v1/chat/completions",
1362 "headers": sanitized_replay_headers(headers),
1363 "body": sanitized_body
1364 }
1365 });
1366 let files = [
1367 ("request.json", request),
1368 (
1369 "prompt_token_ids.json",
1370 serde_json::json!({
1371 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1372 "request_id": request_id,
1373 "model": openai_request.model.clone(),
1374 "tokenizer_or_model": openai_request.model.clone(),
1375 "token_ids": null,
1376 "token_count": null,
1377 "unavailable_reason": "server request replay captures the OpenAI body before prompt token ids are retained",
1378 "sanitized": true
1379 }),
1380 ),
1381 (
1382 "sampling_params.json",
1383 serde_json::json!({
1384 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1385 "request_id": request_id,
1386 "sampling_params": inference_request.sampling_params.clone(),
1387 "unavailable_reason": null
1388 }),
1389 ),
1390 (
1391 "runtime_effective_config.json",
1392 serde_json::json!({
1393 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1394 "request_id": request_id,
1395 "entrypoint": "serve",
1396 "endpoint": "/v1/chat/completions",
1397 "stream": openai_request.stream.unwrap_or(false),
1398 "request_dump_dir": root.to_string_lossy(),
1399 "sanitized": true
1400 }),
1401 ),
1402 (
1403 "backend_selection.json",
1404 serde_json::json!({
1405 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1406 "request_id": request_id,
1407 "backend": "actual",
1408 "model": openai_request.model.clone(),
1409 "actual_model_smoke": true
1410 }),
1411 ),
1412 (
1413 "output_token_ids.json",
1414 serde_json::json!({
1415 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1416 "request_id": request_id,
1417 "token_ids": [],
1418 "token_count": 0,
1419 "finish_reason": null,
1420 "unavailable_reason": "server request replay bundle is emitted at request admission in this WP9 slice"
1421 }),
1422 ),
1423 (
1424 "bad_output_scan.json",
1425 serde_json::json!({
1426 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1427 "request_id": request_id,
1428 "bad_output": false,
1429 "bad_text_count": 0,
1430 "reasons": [],
1431 "first_bad_text_span": null,
1432 "failure_kind": null,
1433 "output_chars": 0,
1434 "classified_output_sha256": sha256_hex(b""),
1435 "output_sha256": sha256_hex(output_text_body.as_bytes())
1436 }),
1437 ),
1438 (
1439 "replay.command.json",
1440 serde_json::json!({
1441 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1442 "request_id": request_id,
1443 "entrypoint": "serve",
1444 "command": replay_curl_command(&bundle_dir),
1445 "argv": replay_curl_argv(&bundle_dir),
1446 "bundle_dir": bundle_dir.to_string_lossy(),
1447 "requires_running_server": true,
1448 "engine_replay": {
1449 "mode": "bundle_offline",
1450 "requires_http_server": false,
1451 "command": shell_command(&engine_replay_argv),
1452 "argv": engine_replay_argv
1453 },
1454 "sanitized": true
1455 }),
1456 ),
1457 ];
1458 for (name, value) in files {
1459 write_json_value(&bundle_dir.join(name), &value)?;
1460 }
1461 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1462 .map_err(|err| err.to_string())?;
1463 Ok(())
1464}
1465
1466fn write_chat_request_failure_diagnostics(
1467 state: &AppState,
1468 request_id: &str,
1469 failure_kind: &str,
1470 phase: &str,
1471 error_kind: &str,
1472 message: &str,
1473 engine_status: Option<&EngineStatus>,
1474) -> std::result::Result<(), String> {
1475 let admission_summary = state
1476 .auto_config
1477 .as_ref()
1478 .map(|config| config.admission_summary_document());
1479 write_chat_request_failure_diagnostics_at_root(
1480 state.request_dump_dir.as_ref().map(|root| root.as_path()),
1481 admission_summary.as_ref(),
1482 engine_status,
1483 request_id,
1484 failure_kind,
1485 phase,
1486 error_kind,
1487 message,
1488 )
1489}
1490
1491fn write_chat_request_completion_replay_bundle(
1492 request_dump_dir: Option<&Path>,
1493 request_id: &str,
1494 output_text: &str,
1495 output_token_ids: &[TokenId],
1496 finish_reason: Option<&str>,
1497) -> std::result::Result<(), String> {
1498 let Some(root) = request_dump_dir else {
1499 return Ok(());
1500 };
1501 let bundle_dir = root.join(request_id);
1502 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1503 let token_ids = output_token_ids
1504 .iter()
1505 .map(|token| token.get())
1506 .collect::<Vec<_>>();
1507 let output_text_body = format!(
1508 "[redacted actual output]\nsha256={}\nchars={}\n",
1509 sha256_hex(output_text.as_bytes()),
1510 output_text.chars().count()
1511 );
1512 write_json_value(
1513 &bundle_dir.join("output_token_ids.json"),
1514 &serde_json::json!({
1515 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1516 "request_id": request_id,
1517 "token_ids": token_ids,
1518 "token_count": output_token_ids.len(),
1519 "finish_reason": finish_reason,
1520 "unavailable_reason": null
1521 }),
1522 )?;
1523 write_json_value(
1524 &bundle_dir.join("bad_output_scan.json"),
1525 &bad_output_scan_json(request_id, output_text, None, output_text_body.as_bytes()),
1526 )?;
1527 fs::write(bundle_dir.join("output_text.txt"), output_text_body)
1528 .map_err(|err| err.to_string())?;
1529 Ok(())
1530}
1531
1532fn write_chat_prompt_token_evidence(
1533 request_dump_dir: Option<&Path>,
1534 request_id: &str,
1535 model: &str,
1536 execution_evidence: Option<&InferenceExecutionEvidence>,
1537) -> std::result::Result<(), String> {
1538 let (Some(root), Some(evidence)) = (request_dump_dir, execution_evidence) else {
1539 return Ok(());
1540 };
1541 let bundle_dir = root.join(request_id);
1542 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1543 let prompt_token_ids = evidence
1544 .prompt_token_ids
1545 .iter()
1546 .map(|token| token.get())
1547 .collect::<Vec<_>>();
1548 write_json_value(
1549 &bundle_dir.join("prompt_token_ids.json"),
1550 &serde_json::json!({
1551 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1552 "request_id": request_id,
1553 "model": model,
1554 "tokenizer_or_model": model,
1555 "token_ids": prompt_token_ids,
1556 "token_count": evidence.prompt_token_ids.len(),
1557 "unavailable_reason": null,
1558 "sanitized": true
1559 }),
1560 )
1561}
1562
1563#[derive(Clone, Copy, Default)]
1564struct ChatRequestProfileTiming<'a> {
1565 engine_evidence: Option<&'a InferenceExecutionEvidence>,
1566 first_engine_chunk_received_us: Option<u64>,
1567 first_sse_enqueue_us: Option<u64>,
1568}
1569
1570#[allow(clippy::too_many_arguments)]
1571fn write_chat_request_profile_event(
1572 state: &AppState,
1573 request_id: &str,
1574 benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1575 model: &str,
1576 stream: bool,
1577 phase: &str,
1578 started_at: Instant,
1579 timing: ChatRequestProfileTiming<'_>,
1580 output_token_count: usize,
1581 usage: Option<&TokenUsage>,
1582 finish_reason: Option<&str>,
1583 error: Option<ProfileError>,
1584) -> std::result::Result<(), String> {
1585 let Some(path) = state.profile_jsonl.as_ref() else {
1586 return Ok(());
1587 };
1588 let timestamp = chrono::Utc::now();
1589 let status = if error.is_some() {
1590 ProfileStatus::Failure
1591 } else {
1592 ProfileStatus::Ok
1593 };
1594 let duration_us = elapsed_us_since(started_at);
1595 let mut attributes = BTreeMap::from([
1596 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1597 (
1598 "diagnostic_only".to_string(),
1599 serde_json::json!(state.profile_detail.diagnostic_only()),
1600 ),
1601 (
1602 "endpoint".to_string(),
1603 serde_json::json!("/v1/chat/completions"),
1604 ),
1605 (
1606 "e2e_duration_us".to_string(),
1607 serde_json::json!(duration_us),
1608 ),
1609 ("l0_only".to_string(), serde_json::json!(false)),
1610 (
1611 "profile_detail".to_string(),
1612 serde_json::json!(state.profile_detail.as_str()),
1613 ),
1614 ("stream".to_string(), serde_json::json!(stream)),
1615 (
1616 "output_token_count".to_string(),
1617 serde_json::json!(output_token_count),
1618 ),
1619 (
1620 "execution_request_id".to_string(),
1621 serde_json::json!(format!("request.product.{request_id}")),
1622 ),
1623 ]);
1624 extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1625 if let Some(usage) = usage {
1626 attributes.insert(
1627 "prompt_token_count".to_string(),
1628 serde_json::json!(usage.prompt_tokens),
1629 );
1630 attributes.insert(
1631 "completion_token_count".to_string(),
1632 serde_json::json!(usage.completion_tokens),
1633 );
1634 attributes.insert(
1635 "total_token_count".to_string(),
1636 serde_json::json!(usage.total_tokens),
1637 );
1638 attributes.insert("token_count_source".to_string(), serde_json::json!("usage"));
1639 } else {
1640 attributes.insert(
1641 "completion_token_count".to_string(),
1642 serde_json::json!(output_token_count),
1643 );
1644 attributes.insert(
1645 "total_token_count".to_string(),
1646 serde_json::json!(output_token_count),
1647 );
1648 attributes.insert(
1649 "token_count_source".to_string(),
1650 serde_json::json!("generated_tokens"),
1651 );
1652 }
1653 if let Some(engine_timing) = timing
1654 .engine_evidence
1655 .and_then(|evidence| evidence.engine_token_timing.as_ref())
1656 {
1657 engine_timing
1658 .validate(output_token_count)
1659 .map_err(|error| format!("invalid engine token timing evidence: {error}"))?;
1660 attributes.extend(ferrum_types::engine_token_timing_profile_attributes(
1661 engine_timing,
1662 ));
1663 } else if status == ProfileStatus::Ok && state.profile_detail.captures_engine_token_timing() {
1664 return Err(format!(
1665 "{} profile completed without required engine token timing evidence",
1666 state.profile_detail.as_str()
1667 ));
1668 }
1669 if let Some(received_us) = timing.first_engine_chunk_received_us {
1670 attributes.insert(
1671 "engine_stream_first_chunk_received_us".to_string(),
1672 serde_json::json!(received_us),
1673 );
1674 }
1675 if let Some(enqueue_us) = timing.first_sse_enqueue_us {
1676 attributes.insert(
1677 "http_first_sse_enqueue_us".to_string(),
1678 serde_json::json!(enqueue_us),
1679 );
1680 }
1681 if stream {
1682 attributes.insert(
1683 "http_stream_flush_unavailable_reason".to_string(),
1684 serde_json::json!(
1685 "socket flush completion is outside the axum handler observation boundary"
1686 ),
1687 );
1688 }
1689 if let Some(reason) = finish_reason {
1690 attributes.insert("finish_reason".to_string(), serde_json::json!(reason));
1691 }
1692 if let Some(error) = error.as_ref() {
1693 attributes.insert(
1694 if error.blocking {
1695 "first_failure_event"
1696 } else {
1697 "terminal_failure_event"
1698 }
1699 .to_string(),
1700 serde_json::json!(true),
1701 );
1702 }
1703
1704 let replay = state.request_dump_dir.as_ref().map(|root| {
1705 let bundle_dir = root.join(request_id);
1706 ReplayReference {
1707 command: replay_curl_command(&bundle_dir),
1708 bundle_dir: Some(root.to_string_lossy().to_string()),
1709 }
1710 });
1711 let resource = error.as_ref().map(|error| ResourceTraceEvent {
1712 owner_kind: "request".to_string(),
1713 owner_id: request_id.to_string(),
1714 resource_kind: "chat_request".to_string(),
1715 action: ResourceAction::Reject,
1716 amount: None,
1717 before: None,
1718 after: None,
1719 capacity: Some(1),
1720 underflow_amount: None,
1721 reason: Some(error.message.clone()),
1722 error_kind: Some(error.kind.clone()),
1723 message: Some(error.message.clone()),
1724 resource_error_kind: Some(error.kind.clone()),
1725 });
1726 let event = FerrumProfileEvent {
1727 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1728 ts_unix_nanos: timestamp
1729 .timestamp_nanos_opt()
1730 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1731 event_id: format!(
1732 "evt-server-chat-{}-{request_id}",
1733 if stream { "stream" } else { "sync" }
1734 ),
1735 request_id: request_id.to_string(),
1736 correlation_id: Some(request_id.to_string()),
1737 entrypoint: ProfileEntrypoint::Serve,
1738 backend: "actual".to_string(),
1739 runtime_preset_hash: state
1740 .auto_config
1741 .as_ref()
1742 .map(ResolvedFerrumConfig::runtime_env_hash)
1743 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1744 phase: phase.to_string(),
1745 event_kind: ProfileEventKind::TimedSpan,
1746 timestamp,
1747 status,
1748 model: Some(model.to_string()),
1749 duration_us: Some(duration_us),
1750 memory: None,
1751 resource,
1752 error,
1753 replay,
1754 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1755 backend_detail: None,
1756 attributes,
1757 };
1758 append_profile_event(path.as_path(), &event)
1759}
1760
1761fn maybe_write_first_request_memory_stage(
1762 state: &AppState,
1763 request_id: &str,
1764 benchmark_correlation: Option<&BenchmarkRequestCorrelation>,
1765 model: &str,
1766 stream: bool,
1767 started_at: Instant,
1768 before: Option<ProcessMemorySample>,
1769) -> std::result::Result<(), String> {
1770 if state.profile_jsonl.is_none() && state.memory_profile_jsonl.is_none() {
1771 return Ok(());
1772 }
1773 if state
1774 .first_request_memory_recorded
1775 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1776 .is_err()
1777 {
1778 return Ok(());
1779 }
1780 let after = ProcessMemorySampler.sample();
1781 let memory = after.map(|after| ProcessMemoryObservation::from_samples(before, after));
1782 let timestamp = chrono::Utc::now();
1783 let mut attributes = BTreeMap::from([
1784 ("actual_model_smoke".to_string(), serde_json::json!(true)),
1785 (
1786 "diagnostic_only".to_string(),
1787 serde_json::json!(state.profile_detail.diagnostic_only()),
1788 ),
1789 (
1790 "endpoint".to_string(),
1791 serde_json::json!("/v1/chat/completions"),
1792 ),
1793 ("l0_only".to_string(), serde_json::json!(false)),
1794 (
1795 "memory_stage".to_string(),
1796 serde_json::json!("first_request_done"),
1797 ),
1798 (
1799 "profile_detail".to_string(),
1800 serde_json::json!(state.profile_detail.as_str()),
1801 ),
1802 ("stream".to_string(), serde_json::json!(stream)),
1803 ]);
1804 extend_benchmark_profile_attributes(&mut attributes, benchmark_correlation);
1805 let memory_snapshot = if let Some(memory) = &memory {
1806 attributes.insert(
1807 "memory_measurement".to_string(),
1808 serde_json::json!("process_rss"),
1809 );
1810 attributes.insert(
1811 "process_memory_source".to_string(),
1812 serde_json::json!(memory.source),
1813 );
1814 memory.to_snapshot("process", Some("actual"))
1815 } else {
1816 attributes.insert(
1817 "memory_measurement".to_string(),
1818 serde_json::json!("not_collected"),
1819 );
1820 ferrum_types::MemorySnapshot {
1821 scope: "process".to_string(),
1822 backend: Some("actual".to_string()),
1823 before_bytes: Some(0),
1824 after_bytes: Some(0),
1825 current_bytes: Some(0),
1826 high_water_bytes: Some(0),
1827 available_bytes: None,
1828 }
1829 };
1830 let replay = state.request_dump_dir.as_ref().map(|root| {
1831 let bundle_dir = root.join(request_id);
1832 ReplayReference {
1833 command: replay_curl_command(&bundle_dir),
1834 bundle_dir: Some(root.to_string_lossy().to_string()),
1835 }
1836 });
1837 let event = FerrumProfileEvent {
1838 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1839 ts_unix_nanos: timestamp
1840 .timestamp_nanos_opt()
1841 .unwrap_or_else(|| timestamp.timestamp_micros() * 1_000),
1842 event_id: format!("evt-server-chat-memory-first-request-{request_id}"),
1843 request_id: request_id.to_string(),
1844 correlation_id: Some(request_id.to_string()),
1845 entrypoint: ProfileEntrypoint::Serve,
1846 backend: "actual".to_string(),
1847 runtime_preset_hash: state
1848 .auto_config
1849 .as_ref()
1850 .map(ResolvedFerrumConfig::runtime_env_hash)
1851 .unwrap_or_else(|| format!("sha256:{}", sha256_hex(b"serve-profile"))),
1852 phase: "actual_serve_first_request_done".to_string(),
1853 event_kind: ProfileEventKind::Memory,
1854 timestamp,
1855 status: ProfileStatus::Ok,
1856 model: Some(model.to_string()),
1857 duration_us: Some(elapsed_us_since(started_at)),
1858 memory: Some(memory_snapshot),
1859 resource: None,
1860 error: None,
1861 replay,
1862 shape: BTreeMap::from([("batch_size".to_string(), serde_json::json!(1))]),
1863 backend_detail: None,
1864 attributes,
1865 };
1866 if let Some(path) = &state.profile_jsonl {
1867 append_profile_event(path.as_path(), &event)?;
1868 }
1869 if let Some(path) = &state.memory_profile_jsonl {
1870 append_profile_event(path.as_path(), &event)?;
1871 }
1872 Ok(())
1873}
1874
1875fn request_memory_sample_before(state: &AppState) -> Option<ProcessMemorySample> {
1876 (state.profile_jsonl.is_some() || state.memory_profile_jsonl.is_some())
1877 .then(|| ProcessMemorySampler.sample())
1878 .flatten()
1879}
1880
1881fn append_profile_event(
1882 path: &Path,
1883 event: &FerrumProfileEvent,
1884) -> std::result::Result<(), String> {
1885 event.validate().map_err(|err| err.to_string())?;
1886 ferrum_bench_core::write_jsonl_records(
1887 path,
1888 ferrum_bench_core::JsonlJournalOpenMode::Append,
1889 std::slice::from_ref(event),
1890 )
1891 .map_err(|error| error.to_string())
1892}
1893
1894fn elapsed_us_since(started_at: Instant) -> u64 {
1895 started_at
1896 .elapsed()
1897 .as_micros()
1898 .max(1)
1899 .try_into()
1900 .unwrap_or(u64::MAX)
1901}
1902
1903fn write_chat_request_failure_diagnostics_at_root(
1904 request_dump_dir: Option<&Path>,
1905 admission_summary: Option<&serde_json::Value>,
1906 engine_status: Option<&EngineStatus>,
1907 request_id: &str,
1908 failure_kind: &str,
1909 phase: &str,
1910 error_kind: &str,
1911 message: &str,
1912) -> std::result::Result<(), String> {
1913 let Some(root) = request_dump_dir else {
1914 return Ok(());
1915 };
1916 let bundle_dir = root.join(request_id);
1917 fs::create_dir_all(&bundle_dir).map_err(|err| err.to_string())?;
1918 let message = sanitize_diagnostic_text(message);
1919 let now = chrono::Utc::now();
1920
1921 let bad_scan_path = bundle_dir.join("bad_output_scan.json");
1922 let mut bad_scan = fs::read_to_string(&bad_scan_path)
1923 .ok()
1924 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
1925 .filter(|value| value.is_object())
1926 .unwrap_or_else(|| serde_json::json!({}));
1927 let bad_scan_obj = bad_scan
1928 .as_object_mut()
1929 .expect("bad scan fallback should be an object");
1930 bad_scan_obj.insert(
1931 "schema_version".to_string(),
1932 serde_json::json!(OBSERVABILITY_PROFILE_SCHEMA_VERSION),
1933 );
1934 bad_scan_obj.insert("request_id".to_string(), serde_json::json!(request_id));
1935 bad_scan_obj
1936 .entry("bad_output".to_string())
1937 .or_insert_with(|| serde_json::json!(false));
1938 bad_scan_obj
1939 .entry("bad_text_count".to_string())
1940 .or_insert_with(|| serde_json::json!(0));
1941 bad_scan_obj
1942 .entry("reasons".to_string())
1943 .or_insert_with(|| serde_json::json!([]));
1944 bad_scan_obj
1945 .entry("first_bad_text_span".to_string())
1946 .or_insert(serde_json::Value::Null);
1947 bad_scan_obj.insert("failure_kind".to_string(), serde_json::json!(failure_kind));
1948 bad_scan_obj.insert("failure_phase".to_string(), serde_json::json!(phase));
1949 bad_scan_obj.insert("error_kind".to_string(), serde_json::json!(error_kind));
1950 bad_scan_obj
1951 .entry("output_chars".to_string())
1952 .or_insert_with(|| serde_json::json!(0));
1953 bad_scan_obj
1954 .entry("output_sha256".to_string())
1955 .or_insert_with(|| serde_json::json!(sha256_hex(b"")));
1956 write_json_value(&bad_scan_path, &bad_scan)?;
1957
1958 let diagnostics = if chat_resource_failure_kind(failure_kind) {
1959 chat_resource_failure_diagnostics(
1960 request_id,
1961 failure_kind,
1962 phase,
1963 error_kind,
1964 &message,
1965 now.timestamp_millis(),
1966 admission_summary,
1967 engine_status,
1968 )
1969 } else {
1970 serde_json::json!({
1971 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1972 "entrypoint": "serve",
1973 "request_id": request_id,
1974 "failure_kind": failure_kind,
1975 "phase": phase,
1976 "first_failure_event": {
1977 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
1978 "entrypoint": "serve",
1979 "request_id": request_id,
1980 "phase": phase,
1981 "error_kind": error_kind,
1982 "message": message,
1983 "timestamp_unix_ms": now.timestamp_millis()
1984 },
1985 "nearest_request_id": request_id,
1986 "log_excerpt": format!("{phase}: {message}"),
1987 "backtrace_excerpt": null,
1988 "nearest_resource_event": null,
1989 "nearest_memory_snapshot": null
1990 })
1991 };
1992 write_json_value(&bundle_dir.join("failure_diagnostics.json"), &diagnostics)?;
1993 Ok(())
1994}
1995
1996fn chat_resource_failure_diagnostics(
1997 request_id: &str,
1998 failure_kind: &str,
1999 phase: &str,
2000 error_kind: &str,
2001 message: &str,
2002 timestamp_unix_ms: i64,
2003 admission_summary: Option<&serde_json::Value>,
2004 engine_status: Option<&EngineStatus>,
2005) -> serde_json::Value {
2006 let resource_kind = chat_resource_kind_for_failure(failure_kind);
2007 let memory = engine_status
2008 .map(|status| &status.memory_usage)
2009 .map(|memory| {
2010 let current = memory.used_bytes as i64;
2011 let high_water = current.max(0);
2012 serde_json::json!({
2013 "scope": "serve_failure",
2014 "backend": "engine_status",
2015 "current_bytes": current.max(0),
2016 "high_water_bytes": high_water,
2017 "total_bytes": memory.total_bytes,
2018 "free_bytes": memory.free_bytes,
2019 "gpu_memory_bytes": memory.gpu_memory_bytes,
2020 "cpu_memory_bytes": memory.cpu_memory_bytes,
2021 "source": "engine_status"
2022 })
2023 })
2024 .unwrap_or_else(|| {
2025 serde_json::json!({
2026 "scope": "serve_failure",
2027 "backend": "engine_status",
2028 "current_bytes": 0,
2029 "high_water_bytes": 0,
2030 "source": "not_collected"
2031 })
2032 });
2033 let capacity = chat_failure_capacity(resource_kind, admission_summary, engine_status, message);
2034 let needed = capacity
2035 .get("needed")
2036 .and_then(|value| value.as_i64())
2037 .unwrap_or(1)
2038 .max(1);
2039 let capacity_value = capacity
2040 .get("capacity")
2041 .and_then(|value| value.as_i64())
2042 .unwrap_or(0)
2043 .max(0);
2044 serde_json::json!({
2045 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2046 "entrypoint": "serve",
2047 "request_id": request_id,
2048 "failure_kind": failure_kind,
2049 "phase": phase,
2050 "first_failure_event": {
2051 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2052 "entrypoint": "serve",
2053 "request_id": request_id,
2054 "phase": phase,
2055 "error_kind": error_kind,
2056 "message": message,
2057 "timestamp_unix_ms": timestamp_unix_ms
2058 },
2059 "nearest_request_id": request_id,
2060 "log_excerpt": format!("{phase}: {message}"),
2061 "capacity": capacity,
2062 "nearest_resource_event": {
2063 "owner_kind": "request",
2064 "owner_id": request_id,
2065 "resource_kind": resource_kind,
2066 "action": "reject",
2067 "amount": needed,
2068 "before": capacity_value,
2069 "after": capacity_value,
2070 "capacity": capacity_value,
2071 "reason": message
2072 },
2073 "nearest_memory_snapshot": memory
2074 })
2075}
2076
2077fn chat_failure_capacity(
2078 resource_kind: &str,
2079 admission_summary: Option<&serde_json::Value>,
2080 engine_status: Option<&EngineStatus>,
2081 reason: &str,
2082) -> serde_json::Value {
2083 if resource_kind == "device_memory" {
2084 let (needed, available, capacity) = engine_status
2085 .map(|status| {
2086 let memory = &status.memory_usage;
2087 let used = memory.used_bytes as i64;
2088 let available = memory.free_bytes as i64;
2089 let capacity = memory.total_bytes as i64;
2090 (
2091 used.saturating_add(1).max(1),
2092 available.max(0),
2093 capacity.max(0),
2094 )
2095 })
2096 .unwrap_or((1, 0, 0));
2097 return serde_json::json!({
2098 "resource_kind": resource_kind,
2099 "needed": needed,
2100 "available": available,
2101 "capacity": capacity,
2102 "reason": reason
2103 });
2104 }
2105 let capacity = admission_summary
2106 .and_then(|summary| summary.get("effective_max_concurrent"))
2107 .and_then(|value| {
2108 value
2109 .as_i64()
2110 .or_else(|| value.as_u64().map(|value| value as i64))
2111 })
2112 .unwrap_or_else(|| {
2113 engine_status
2114 .map(|status| {
2115 (status.active_requests as i64)
2116 .saturating_add(status.queued_requests as i64)
2117 .saturating_add(1)
2118 })
2119 .unwrap_or(0)
2120 })
2121 .max(0);
2122 let used = engine_status
2123 .map(|status| (status.active_requests as i64).saturating_add(status.queued_requests as i64))
2124 .unwrap_or(0)
2125 .max(0);
2126 serde_json::json!({
2127 "resource_kind": resource_kind,
2128 "needed": 1,
2129 "available": capacity.saturating_sub(used),
2130 "capacity": capacity,
2131 "reason": reason
2132 })
2133}
2134
2135fn chat_resource_failure_kind(failure_kind: &str) -> bool {
2136 matches!(
2137 failure_kind,
2138 "oom" | "prevented_oom" | "admission" | "admission_reject" | "oom_admission"
2139 )
2140}
2141
2142fn chat_resource_kind_for_failure(failure_kind: &str) -> &'static str {
2143 match failure_kind {
2144 "oom" | "prevented_oom" => "device_memory",
2145 _ => "admission_capacity",
2146 }
2147}
2148
2149fn sanitize_diagnostic_text(message: &str) -> String {
2150 let trimmed = message.trim();
2151 if trimmed.is_empty() {
2152 return "generation failed without an error message".to_string();
2153 }
2154 let lower = trimmed.to_ascii_lowercase();
2155 if lower.contains("authorization")
2156 || lower.contains("cookie")
2157 || lower.contains("api_key")
2158 || lower.contains("access_token")
2159 || lower.contains("refresh_token")
2160 || lower.contains("password")
2161 || trimmed.contains("sk-")
2162 {
2163 return "[redacted diagnostic message]".to_string();
2164 }
2165 trimmed.chars().take(2048).collect()
2166}
2167
2168fn sanitized_replay_headers(headers: &HeaderMap) -> serde_json::Value {
2169 let mut result = serde_json::Map::new();
2170 for key in ["content-type", "traceparent", "tracestate"] {
2171 if let Some(value) = headers.get(key).and_then(|value| value.to_str().ok()) {
2172 result.insert(key.to_string(), serde_json::json!(value));
2173 }
2174 }
2175 result.insert("authorization".to_string(), serde_json::json!("[redacted]"));
2176 result.insert("cookie".to_string(), serde_json::json!("[redacted]"));
2177 serde_json::Value::Object(result)
2178}
2179
2180fn sanitized_chat_request_body(request: &ChatCompletionsRequest) -> serde_json::Value {
2181 let mut value = serde_json::to_value(request).unwrap_or_else(|_| {
2182 serde_json::json!({
2183 "model": request.model.clone(),
2184 "stream": request.stream.unwrap_or(false),
2185 "messages": []
2186 })
2187 });
2188 redact_json_value(&mut value, None);
2189 value
2190}
2191
2192fn redact_json_value(value: &mut serde_json::Value, key: Option<&str>) {
2193 if key.is_some_and(is_secret_key) {
2194 *value = serde_json::json!("[redacted]");
2195 return;
2196 }
2197 if matches!(key, Some("content" | "arguments")) && value.is_string() {
2198 *value = serde_json::json!("[redacted]");
2199 return;
2200 }
2201 match value {
2202 serde_json::Value::Object(map) => {
2203 for field in ["content", "arguments"] {
2204 if let Some(chars) = map
2205 .get(field)
2206 .and_then(|child| child.as_str())
2207 .map(|text| text.chars().count())
2208 {
2209 map.insert(field.to_string(), serde_json::json!("[redacted]"));
2210 map.insert(format!("{field}_redacted"), serde_json::json!(true));
2211 map.insert(format!("{field}_chars"), serde_json::json!(chars));
2212 }
2213 }
2214 for (child_key, child) in map.iter_mut() {
2215 redact_json_value(child, Some(child_key.as_str()));
2216 }
2217 }
2218 serde_json::Value::Array(items) => {
2219 for child in items {
2220 redact_json_value(child, None);
2221 }
2222 }
2223 _ => {}
2224 }
2225}
2226
2227fn is_secret_key(key: &str) -> bool {
2228 let normalized = key
2229 .chars()
2230 .filter(|ch| *ch != '-' && *ch != '_')
2231 .flat_map(char::to_lowercase)
2232 .collect::<String>();
2233 matches!(
2234 normalized.as_str(),
2235 "authorization"
2236 | "cookie"
2237 | "secret"
2238 | "apikey"
2239 | "password"
2240 | "accesstoken"
2241 | "refreshtoken"
2242 | "idtoken"
2243 )
2244}
2245
2246fn replay_curl_argv(bundle_dir: &Path) -> Vec<String> {
2247 vec![
2248 "curl".to_string(),
2249 "-sS".to_string(),
2250 "-X".to_string(),
2251 "POST".to_string(),
2252 "http://127.0.0.1:8000/v1/chat/completions".to_string(),
2253 "-H".to_string(),
2254 "content-type: application/json".to_string(),
2255 "--data-binary".to_string(),
2256 format!("@{}", bundle_dir.join("replay_body.json").display()),
2257 ]
2258}
2259
2260fn replay_curl_command(bundle_dir: &Path) -> String {
2261 shell_command(&replay_curl_argv(bundle_dir))
2262}
2263
2264fn replay_bundle_argv(bundle_dir: &Path) -> Vec<String> {
2265 vec![
2266 "cargo".to_string(),
2267 "run".to_string(),
2268 "-p".to_string(),
2269 "ferrum-cli".to_string(),
2270 "--".to_string(),
2271 "replay-bundle".to_string(),
2272 bundle_dir.to_string_lossy().to_string(),
2273 "--out".to_string(),
2274 bundle_dir
2275 .join("engine_replay")
2276 .to_string_lossy()
2277 .to_string(),
2278 "--json".to_string(),
2279 ]
2280}
2281
2282fn shell_command(argv: &[String]) -> String {
2283 argv.iter()
2284 .map(|part| shell_quote(part))
2285 .collect::<Vec<_>>()
2286 .join(" ")
2287}
2288
2289fn shell_quote(value: &str) -> String {
2290 if value
2291 .chars()
2292 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '@'))
2293 {
2294 value.to_string()
2295 } else {
2296 format!("'{}'", value.replace('\'', "'\\''"))
2297 }
2298}
2299
2300fn write_json_value(path: &Path, value: &serde_json::Value) -> std::result::Result<(), String> {
2301 let bytes = serde_json::to_vec_pretty(value).map_err(|err| err.to_string())?;
2302 fs::write(path, [bytes, b"\n".to_vec()].concat()).map_err(|err| err.to_string())
2303}
2304
2305fn bad_output_scan_json(
2306 request_id: &str,
2307 text: &str,
2308 failure_kind: Option<&str>,
2309 output_artifact_bytes: &[u8],
2310) -> serde_json::Value {
2311 let mut reasons = Vec::new();
2312 let mut first_span: Option<serde_json::Value> = None;
2313 for (needle, reason) in [
2314 ("<unk>", "reserved_token"),
2315 ("[PAD", "reserved_token"),
2316 ("<pad>", "reserved_token"),
2317 ("<|endoftext|>", "reserved_token"),
2318 ("<|im_start|>", "reserved_token"),
2319 ("<|im_end|>", "reserved_token"),
2320 ("<|reserved_special_token", "reserved_token"),
2321 ("\u{fffd}", "invalid_utf8"),
2322 ] {
2323 if let Some(index) = text.find(needle) {
2324 reasons.push(reason);
2325 first_span.get_or_insert_with(|| {
2326 serde_json::json!({
2327 "byte_start": index,
2328 "byte_end": index + needle.len(),
2329 "text": needle,
2330 "reason": reason
2331 })
2332 });
2333 }
2334 }
2335 if let Some(index) = first_mojibake_index(text) {
2336 reasons.push("mojibake");
2337 first_span.get_or_insert_with(|| {
2338 serde_json::json!({
2339 "byte_start": index,
2340 "byte_end": index + 1,
2341 "reason": "mojibake"
2342 })
2343 });
2344 }
2345 reasons.sort_unstable();
2346 reasons.dedup();
2347 let bad_output = !reasons.is_empty();
2348 serde_json::json!({
2349 "schema_version": OBSERVABILITY_PROFILE_SCHEMA_VERSION,
2350 "request_id": request_id,
2351 "bad_output": bad_output,
2352 "bad_text_count": if bad_output { 1 } else { 0 },
2353 "reasons": reasons,
2354 "first_bad_text_span": first_span,
2355 "failure_kind": failure_kind,
2356 "output_chars": text.chars().count(),
2357 "classified_output_sha256": sha256_hex(text.as_bytes()),
2358 "output_sha256": sha256_hex(output_artifact_bytes)
2359 })
2360}
2361
2362fn first_mojibake_index(text: &str) -> Option<usize> {
2363 let mut chars = text.char_indices().peekable();
2364 while let Some((index, ch)) = chars.next() {
2365 match ch {
2366 '\u{00c2}' | '\u{00c3}' => {
2367 if chars.peek().is_some_and(|(_, next)| !next.is_ascii()) {
2368 return Some(index);
2369 }
2370 }
2371 '\u{00e2}' => {
2372 if chars.peek().is_some_and(|(_, next)| *next == '\u{20ac}') {
2373 return Some(index);
2374 }
2375 }
2376 _ => {}
2377 }
2378 }
2379 None
2380}
2381
2382fn sha256_hex(bytes: &[u8]) -> String {
2383 let mut hasher = Sha256::new();
2384 hasher.update(bytes);
2385 format!("{:x}", hasher.finalize())
2386}
2387
2388struct ParsedChatModelOutput {
2389 visible: ParsedReasoningResponse,
2390 harmony_response: Option<ferrum_types::ApiChatResponse>,
2391}
2392
2393fn parse_chat_model_output(
2394 protocol: ModelOutputProtocol,
2395 text: &str,
2396 started_in_think: bool,
2397 finish_reason: FinishReason,
2398) -> std::result::Result<ParsedChatModelOutput, ServerError> {
2399 match protocol {
2400 ModelOutputProtocol::Text | ModelOutputProtocol::GemmaThought => {
2401 Ok(ParsedChatModelOutput {
2402 visible: parse_model_reasoning_response(protocol, text, started_in_think)
2403 .map_err(|error| ServerError::InternalError(error.to_string()))?,
2404 harmony_response: None,
2405 })
2406 }
2407 ModelOutputProtocol::HarmonyGptOss => {
2408 let parsed = parse_harmony_response_for_finish_reason(text, Some(finish_reason))
2409 .map_err(|error| {
2410 ServerError::InternalError(format!(
2411 "model output did not satisfy the GPT-OSS Harmony protocol: {error}"
2412 ))
2413 })?;
2414 let harmony_response =
2415 parsed
2416 .tool_call
2417 .map(|tool_call| ferrum_types::ApiChatResponse {
2418 message: ferrum_types::ApiChatMessage {
2419 role: ferrum_types::ApiMessageRole::Assistant,
2420 content: String::new(),
2421 name: None,
2422 tool_calls: vec![ferrum_types::ApiToolCall {
2423 id: format!("call_{}", Uuid::new_v4().simple()),
2424 tool_type: "function".to_string(),
2425 function: ferrum_types::ApiFunctionCall {
2426 name: tool_call.name,
2427 arguments: tool_call.arguments_json,
2428 },
2429 }],
2430 tool_call_id: None,
2431 function_call: None,
2432 },
2433 finish_reason: Some("tool_calls".to_string()),
2434 });
2435 Ok(ParsedChatModelOutput {
2436 visible: ParsedReasoningResponse {
2437 content: parsed.content,
2438 reasoning: parsed.reasoning_content,
2439 },
2440 harmony_response,
2441 })
2442 }
2443 }
2444}
2445
2446async fn handle_chat_completions_stream(
2448 state: AppState,
2449 openai_request: ChatCompletionsRequest,
2450 inference_request: InferenceRequest,
2451 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
2452) -> std::result::Result<Response, ServerError> {
2453 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2454
2455 let engine = state.llm.clone().ok_or_else(|| {
2457 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
2458 })?;
2459 let request_id = inference_request.id.to_string();
2460 let include_stream_usage = openai_request
2461 .stream_options
2462 .as_ref()
2463 .and_then(|opts| opts.include_usage)
2464 .unwrap_or(false);
2465 let output_contract = EffectiveChatOutputContract::resolve(&openai_request);
2466 let buffer_json_object_stream = matches!(
2467 output_contract,
2468 EffectiveChatOutputContract::JsonObjectContent
2469 );
2470 let buffer_strict_json_schema_stream = matches!(
2471 output_contract,
2472 EffectiveChatOutputContract::StrictJsonSchemaContent
2473 );
2474 let stream_api_request = match inference_request.api_request.as_ref() {
2475 Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
2476 _ => api_chat_request(
2477 &openai_request,
2478 openai_request.tool_choice.as_ref(),
2479 ferrum_types::ApiToolCallProtocol::default(),
2480 ),
2481 };
2482 let buffer_structured_api_stream =
2483 ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
2484 let model_output_protocol = inference_request.sampling_params.model_output_protocol;
2485 let buffer_stream_output = buffer_json_object_stream
2486 || buffer_strict_json_schema_stream
2487 || buffer_structured_api_stream
2488 || model_output_protocol == ModelOutputProtocol::HarmonyGptOss;
2489 let started_in_think = request_started_in_reasoning(&inference_request);
2492 let mut native_projector = NativeChatOutputProjector::for_request(&inference_request);
2493 let replay_request_id = inference_request.id.to_string();
2494 let profile_request_model = openai_request.model.clone();
2495 let profile_started_at = Instant::now();
2496 let request_memory_before = request_memory_sample_before(&state);
2497 let mut stream = match engine.infer_stream(inference_request).await {
2498 Ok(stream) => stream,
2499 Err(e) => {
2500 let failure_kind = e.observability_failure_kind();
2501 let error_kind = e.observability_error_kind();
2502 let error_message = e.to_string();
2503 if let Err(err) = write_chat_request_profile_event(
2504 &state,
2505 &replay_request_id,
2506 benchmark_correlation.as_ref(),
2507 &profile_request_model,
2508 true,
2509 "chat_completions_stream_start",
2510 profile_started_at,
2511 ChatRequestProfileTiming::default(),
2512 0,
2513 None,
2514 Some("error"),
2515 Some(ProfileError {
2516 kind: error_kind.to_string(),
2517 message: error_message.clone(),
2518 blocking: false,
2519 }),
2520 ) {
2521 warn!("failed to write chat stream failure profile event: {}", err);
2522 }
2523 let engine_status = if chat_resource_failure_kind(failure_kind) {
2524 Some(engine.status().await)
2525 } else {
2526 None
2527 };
2528 error!(
2529 "Stream generation failed before first chunk: {}",
2530 error_message
2531 );
2532 if let Err(err) = write_chat_request_failure_diagnostics(
2533 &state,
2534 &replay_request_id,
2535 failure_kind,
2536 "chat_completions_stream_start",
2537 error_kind,
2538 &error_message,
2539 engine_status.as_ref(),
2540 ) {
2541 warn!("failed to write chat stream failure diagnostics: {}", err);
2542 }
2543 return Err(server_error_from_ferrum_error(e));
2544 }
2545 };
2546 let request_dump_dir = state.request_dump_dir.clone();
2547 let admission_summary = state
2548 .auto_config
2549 .as_ref()
2550 .map(|config| config.admission_summary_document());
2551 let diagnostics_engine = engine.clone();
2552 let profile_state = state.clone();
2553
2554 tokio::spawn(async move {
2555 let mut current_text = String::new();
2556 let mut output_token_ids = Vec::new();
2557 let mut first_engine_chunk_received_us = None;
2558 let mut first_sse_enqueue_us = None;
2559 let mut sent_reasoning_len = 0usize;
2560 let mut sent_content_len = 0usize;
2561
2562 loop {
2563 let next = tokio::select! {
2564 biased;
2565 _ = tx.closed() => break,
2566 next = stream.next() => next,
2567 };
2568 let Some(result) = next else {
2569 break;
2570 };
2571 match result {
2572 Ok(chunk) => {
2573 if first_engine_chunk_received_us.is_none()
2574 && (chunk.token.is_some() || !chunk.text.is_empty())
2575 {
2576 first_engine_chunk_received_us = Some(elapsed_us_since(profile_started_at));
2577 }
2578 if let Some(token) = chunk.token {
2579 output_token_ids.push(token);
2580 }
2581 if !chunk.text.is_empty() {
2582 current_text.push_str(&chunk.text);
2583 if let Some(projector) = native_projector.as_mut() {
2584 projector.push(&chunk.text);
2585 }
2586
2587 if native_projector.is_some()
2588 || (!buffer_stream_output
2589 && !should_defer_model_reasoning_stream_delta(
2590 model_output_protocol,
2591 ¤t_text,
2592 ))
2593 {
2594 let parsed_result = if let Some(projector) = native_projector.as_ref() {
2595 Ok(ParsedReasoningResponse {
2596 content: projector.visible_prefix().to_owned(),
2597 reasoning: projector.reasoning_prefix().map(str::to_owned),
2598 })
2599 } else {
2600 parse_model_reasoning_response(
2601 model_output_protocol,
2602 ¤t_text,
2603 started_in_think,
2604 )
2605 };
2606 let parsed = match parsed_result {
2607 Ok(parsed) => parsed,
2608 Err(error) => {
2609 let _ = tx.send(Ok(openai_error_sse_event(
2610 error.to_string(),
2611 "internal_server_error",
2612 Some("model_output"),
2613 )));
2614 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2615 break;
2616 }
2617 };
2618 let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
2619 let reasoning_delta =
2620 stream_text_delta(full_reasoning, &mut sent_reasoning_len);
2621 let content_delta =
2622 stream_text_delta(&parsed.content, &mut sent_content_len);
2623 if !reasoning_delta.is_empty() || !content_delta.is_empty() {
2624 let response_chunk = ChatCompletionsResponse {
2626 id: request_id.clone(),
2627 object: "chat.completion.chunk".to_string(),
2628 created: chrono::Utc::now().timestamp() as u64,
2629 model: openai_request.model.clone(),
2630 choices: vec![ChatChoice {
2631 index: 0,
2632 message: None,
2633 delta: Some(ChatMessage {
2634 role: MessageRole::Assistant,
2635 content: content_delta,
2636 reasoning: (!reasoning_delta.is_empty())
2637 .then_some(reasoning_delta),
2638 name: None,
2639 tool_calls: None,
2640 tool_call_id: None,
2641 function_call: None,
2642 }),
2643 finish_reason: None,
2644 }],
2645 usage: None,
2646 };
2647
2648 let sse_event = Event::default()
2649 .json_data(&response_chunk)
2650 .unwrap_or_else(|_| Event::default().data("error"));
2651 if tx.send(Ok(sse_event)).is_err() {
2652 break;
2653 }
2654 first_sse_enqueue_us
2655 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2656 }
2657 }
2658 }
2659
2660 if chunk.finish_reason.is_some() {
2661 let terminal_finish_reason = chunk
2662 .finish_reason
2663 .expect("finish_reason presence checked above");
2664 if let Err(err) = write_chat_prompt_token_evidence(
2665 request_dump_dir.as_ref().map(|root| root.as_path()),
2666 &replay_request_id,
2667 &profile_request_model,
2668 chunk.execution_evidence.as_ref(),
2669 ) {
2670 warn!("failed to write chat stream prompt-token evidence: {}", err);
2671 }
2672 let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
2673 let native_projected = native_projector
2674 .take()
2675 .map(|projector| projector.finish(terminal_finish_reason));
2676 let parsed_output_result =
2677 if let Some(projected) = native_projected.as_ref() {
2678 Ok(ParsedChatModelOutput {
2679 visible: projected.visible.clone(),
2680 harmony_response: None,
2681 })
2682 } else {
2683 parse_chat_model_output(
2684 model_output_protocol,
2685 ¤t_text,
2686 started_in_think,
2687 terminal_finish_reason,
2688 )
2689 };
2690 let parsed_model_output = match parsed_output_result {
2691 Ok(parsed) => parsed,
2692 Err(error) => {
2693 let error_event = openai_error_sse_event(
2694 stream_validation_error_message(error),
2695 "internal_server_error",
2696 Some("model_output"),
2697 );
2698 let _ = tx.send(Ok(error_event));
2699 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2700 break;
2701 }
2702 };
2703 let mut parsed_final = parsed_model_output.visible;
2704 parsed_final.content = normalize_structured_response_content(
2705 &openai_request,
2706 &parsed_final.content,
2707 );
2708 let mut structured_chat_response =
2709 finish_reason_allows_structured_api_response(terminal_finish_reason)
2710 .then(|| match chunk.api_response.as_ref() {
2711 _ if model_output_protocol
2714 == ModelOutputProtocol::HarmonyGptOss =>
2715 {
2716 parsed_model_output.harmony_response.clone()
2717 }
2718 Some(ferrum_types::ApiResponse::Chat(response)) => {
2719 Some(response.clone())
2720 }
2721 _ if native_projected.is_some() => native_projected
2722 .as_ref()
2723 .and_then(|projected| projected.api_response.clone()),
2724 _ if buffer_structured_api_stream => {
2725 chat_api_response_from_parsed_generated_text(
2726 &stream_api_request,
2727 &parsed_final,
2728 terminal_finish_reason,
2729 )
2730 }
2731 _ => None,
2732 })
2733 .flatten();
2734
2735 if native_projected.is_none()
2736 && matches!(
2737 chunk.api_response,
2738 Some(ferrum_types::ApiResponse::Chat(_))
2739 )
2740 {
2741 if let Some(response) = structured_chat_response.as_mut() {
2742 if let Err(error) = project_typed_tool_response_content(
2743 response,
2744 model_output_protocol,
2745 started_in_think,
2746 ) {
2747 let _ = tx.send(Ok(openai_error_sse_event(
2748 stream_validation_error_message(error),
2749 "internal_server_error",
2750 Some("model_output"),
2751 )));
2752 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2753 break;
2754 }
2755 }
2756 }
2757
2758 if let Some(chat_response) = structured_chat_response.as_ref() {
2759 if let Err(e) =
2760 validate_structured_tool_response(&openai_request, chat_response)
2761 {
2762 let error_event = openai_error_sse_event(
2763 stream_validation_error_message(e),
2764 "internal_server_error",
2765 Some("tool_choice"),
2766 );
2767 let _ = tx.send(Ok(error_event));
2768 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2769 break;
2770 }
2771 } else if tool_choice_required(&openai_request) {
2772 log_required_tool_choice_failure(
2773 &openai_request,
2774 &parsed_final.content,
2775 parsed_final.reasoning.as_deref(),
2776 );
2777 let error_event = openai_error_sse_event(
2778 "model output did not satisfy required tool_choice",
2779 "invalid_request_error",
2780 Some("tool_choice"),
2781 );
2782 let _ = tx.send(Ok(error_event));
2783 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2784 break;
2785 }
2786 if let Err(e) = validate_hard_structured_response(
2787 &openai_request,
2788 &parsed_final.content,
2789 structured_chat_response.as_ref(),
2790 ) {
2791 let error_event = openai_error_sse_event(
2792 stream_validation_error_message(e),
2793 "internal_server_error",
2794 structured_response_error_param(output_contract),
2795 );
2796 let _ = tx.send(Ok(error_event));
2797 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2798 break;
2799 }
2800
2801 if let Some(chat_response) = structured_chat_response.as_ref() {
2802 let mut delta = openai_chat_delta_from_api(&chat_response.message);
2803 if native_projected.is_some() {
2804 delta.content =
2805 stream_text_delta(&delta.content, &mut sent_content_len);
2806 }
2807 if delta.reasoning.is_none() {
2808 delta.reasoning = parsed_final.reasoning.clone();
2809 }
2810 if native_projected.is_some() {
2811 let reasoning = stream_text_delta(
2812 delta.reasoning.as_deref().unwrap_or(""),
2813 &mut sent_reasoning_len,
2814 );
2815 delta.reasoning = (!reasoning.is_empty()).then_some(reasoning);
2816 }
2817 let response_chunk = ChatCompletionsResponse {
2818 id: request_id.clone(),
2819 object: "chat.completion.chunk".to_string(),
2820 created: chrono::Utc::now().timestamp() as u64,
2821 model: openai_request.model.clone(),
2822 choices: vec![ChatChoice {
2823 index: 0,
2824 message: None,
2825 delta: Some(delta),
2826 finish_reason: None,
2827 }],
2828 usage: None,
2829 };
2830
2831 let sse_event = Event::default()
2832 .json_data(&response_chunk)
2833 .unwrap_or_else(|_| Event::default().data("error"));
2834 if tx.send(Ok(sse_event)).is_err() {
2835 break;
2836 }
2837 first_sse_enqueue_us
2838 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2839 } else if buffer_structured_api_stream
2840 && parsed_final.content.trim().is_empty()
2841 && terminal_finish_reason != FinishReason::Length
2845 {
2846 let error_event = openai_error_sse_event(
2847 "model output did not satisfy tool/function call request",
2848 "internal_server_error",
2849 Some("tool_choice"),
2850 );
2851 let _ = tx.send(Ok(error_event));
2852 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2853 break;
2854 } else if !current_text.is_empty() {
2855 let content_delta =
2859 stream_text_delta(&parsed_final.content, &mut sent_content_len);
2860 let reasoning_delta = stream_text_delta(
2861 parsed_final.reasoning.as_deref().unwrap_or(""),
2862 &mut sent_reasoning_len,
2863 );
2864 if !content_delta.is_empty() || !reasoning_delta.is_empty() {
2865 let response_chunk = ChatCompletionsResponse {
2866 id: request_id.clone(),
2867 object: "chat.completion.chunk".to_string(),
2868 created: chrono::Utc::now().timestamp() as u64,
2869 model: openai_request.model.clone(),
2870 choices: vec![ChatChoice {
2871 index: 0,
2872 message: None,
2873 delta: Some(ChatMessage {
2874 role: MessageRole::Assistant,
2875 content: content_delta,
2876 reasoning: (!reasoning_delta.is_empty())
2877 .then_some(reasoning_delta),
2878 name: None,
2879 tool_calls: None,
2880 tool_call_id: None,
2881 function_call: None,
2882 }),
2883 finish_reason: None,
2884 }],
2885 usage: None,
2886 };
2887
2888 let sse_event = Event::default()
2889 .json_data(&response_chunk)
2890 .unwrap_or_else(|_| Event::default().data("error"));
2891 if tx.send(Ok(sse_event)).is_err() {
2892 break;
2893 }
2894 first_sse_enqueue_us
2895 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2896 }
2897 }
2898 let final_finish_reason = structured_chat_response
2908 .as_ref()
2909 .and_then(|response| response.finish_reason.clone())
2910 .or_else(|| chunk.finish_reason.as_ref().map(finish_reason_to_string))
2911 .or(Some("length".to_string()));
2912 let final_chunk = ChatCompletionsResponse {
2913 id: request_id.clone(),
2914 object: "chat.completion.chunk".to_string(),
2915 created: chrono::Utc::now().timestamp() as u64,
2916 model: openai_request.model.clone(),
2917 choices: vec![ChatChoice {
2918 index: 0,
2919 message: None,
2920 delta: Some(ChatMessage {
2921 role: MessageRole::Assistant,
2922 content: String::new(),
2923 reasoning: None,
2924 name: None,
2925 tool_calls: None,
2926 tool_call_id: None,
2927 function_call: None,
2928 }),
2929 finish_reason: final_finish_reason.clone(),
2930 }],
2931 usage: None,
2932 };
2933
2934 let final_event = Event::default()
2935 .json_data(&final_chunk)
2936 .unwrap_or_else(|_| Event::default().data("error"));
2937 if tx.send(Ok(final_event)).is_ok() {
2938 first_sse_enqueue_us
2939 .get_or_insert_with(|| elapsed_us_since(profile_started_at));
2940 }
2941 let completion_token_count = chunk
2942 .usage
2943 .as_ref()
2944 .map(|usage| usage.completion_tokens)
2945 .unwrap_or(output_token_ids.len());
2946 let replay_output_token_ids = chunk
2947 .execution_evidence
2948 .as_ref()
2949 .map(|evidence| evidence.output_token_ids.as_slice())
2950 .filter(|tokens| tokens.len() == completion_token_count)
2951 .unwrap_or(output_token_ids.as_slice());
2952 if let Err(err) = write_chat_request_completion_replay_bundle(
2953 request_dump_dir.as_ref().map(|root| root.as_path()),
2954 &replay_request_id,
2955 &parsed_final.content,
2956 replay_output_token_ids,
2957 final_finish_reason.as_deref(),
2958 ) {
2959 warn!("failed to write chat stream replay bundle: {}", err);
2960 }
2961 if let Err(err) = write_chat_request_profile_event(
2962 &profile_state,
2963 &replay_request_id,
2964 benchmark_correlation.as_ref(),
2965 &profile_request_model,
2966 true,
2967 "chat_completions_stream_complete",
2968 profile_started_at,
2969 ChatRequestProfileTiming {
2970 engine_evidence: chunk.execution_evidence.as_ref(),
2971 first_engine_chunk_received_us,
2972 first_sse_enqueue_us,
2973 },
2974 completion_token_count,
2975 chunk.usage.as_ref(),
2976 final_finish_reason.as_deref(),
2977 None,
2978 ) {
2979 warn!("failed to write chat stream profile event: {}", err);
2980 }
2981 if let Err(err) = maybe_write_first_request_memory_stage(
2982 &profile_state,
2983 &replay_request_id,
2984 benchmark_correlation.as_ref(),
2985 &profile_request_model,
2986 true,
2987 profile_started_at,
2988 request_memory_before,
2989 ) {
2990 warn!("failed to write chat stream memory profile event: {}", err);
2991 }
2992 if include_stream_usage && usage.is_some() {
2993 let usage_chunk = ChatCompletionsResponse {
2994 id: request_id.clone(),
2995 object: "chat.completion.chunk".to_string(),
2996 created: chrono::Utc::now().timestamp() as u64,
2997 model: openai_request.model.clone(),
2998 choices: vec![],
2999 usage,
3000 };
3001 let usage_event = Event::default()
3002 .json_data(&usage_chunk)
3003 .unwrap_or_else(|_| Event::default().data("error"));
3004 let _ = tx.send(Ok(usage_event));
3005 }
3006 let _ = tx.send(Ok(Event::default().data("[DONE]")));
3007 break;
3008 }
3009 }
3010 Err(e) => {
3011 let failure_kind = e.observability_failure_kind();
3012 let error_kind = e.observability_error_kind();
3013 let error_message = e.to_string();
3014 let engine_status = if chat_resource_failure_kind(failure_kind) {
3015 Some(diagnostics_engine.status().await)
3016 } else {
3017 None
3018 };
3019 error!("Stream generation error: {}", error_message);
3020 if let Err(err) = write_chat_request_profile_event(
3021 &profile_state,
3022 &replay_request_id,
3023 benchmark_correlation.as_ref(),
3024 &profile_request_model,
3025 true,
3026 "chat_completions_stream_next",
3027 profile_started_at,
3028 ChatRequestProfileTiming {
3029 engine_evidence: None,
3030 first_engine_chunk_received_us,
3031 first_sse_enqueue_us,
3032 },
3033 output_token_ids.len(),
3034 None,
3035 Some("error"),
3036 Some(ProfileError {
3037 kind: error_kind.to_string(),
3038 message: error_message.clone(),
3039 blocking: false,
3040 }),
3041 ) {
3042 warn!("failed to write chat stream chunk profile event: {}", err);
3043 }
3044 if let Err(err) = write_chat_request_failure_diagnostics_at_root(
3045 request_dump_dir.as_ref().map(|root| root.as_path()),
3046 admission_summary.as_ref(),
3047 engine_status.as_ref(),
3048 &replay_request_id,
3049 failure_kind,
3050 "chat_completions_stream_next",
3051 error_kind,
3052 &error_message,
3053 ) {
3054 warn!(
3055 "failed to write chat stream chunk failure diagnostics: {}",
3056 err
3057 );
3058 }
3059 let _ = tx.send(Ok(openai_error_sse_event(
3060 error_message,
3061 "internal_server_error",
3062 None,
3063 )));
3064 let _ = tx.send(Ok(Event::default().data("[DONE]")));
3065 break;
3066 }
3067 }
3068 }
3069 });
3070
3071 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
3072 let sse_stream = Sse::new(stream);
3073
3074 Ok(sse_stream.into_response())
3075}
3076
3077async fn handle_chat_completions_sync(
3079 state: AppState,
3080 openai_request: ChatCompletionsRequest,
3081 inference_request: InferenceRequest,
3082 session_context: Option<SessionContext>,
3083 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
3084) -> std::result::Result<Response, ServerError> {
3085 info!("Processing non-streaming chat completion");
3086
3087 let engine = state.llm.clone().ok_or_else(|| {
3088 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
3089 })?;
3090 let request_chat_api = inference_request
3091 .api_request
3092 .as_ref()
3093 .and_then(|api_request| match api_request {
3094 ferrum_types::ApiRequest::Chat(chat_request) => {
3095 ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
3096 .then(|| chat_request.clone())
3097 }
3098 _ => None,
3099 });
3100 let model_output_protocol = inference_request.sampling_params.model_output_protocol;
3101 let started_in_think = request_started_in_reasoning(&inference_request);
3103 let mut native_projector = NativeChatOutputProjector::for_request(&inference_request);
3104 let replay_request_id = inference_request.id.to_string();
3105 let profile_request_model = openai_request.model.clone();
3106 let profile_started_at = Instant::now();
3107 let request_memory_before = request_memory_sample_before(&state);
3108 match engine.infer(inference_request).await {
3109 Ok(output) => {
3110 let InferenceResponse {
3111 text: output_text,
3112 tokens,
3113 finish_reason,
3114 usage,
3115 api_response,
3116 execution_evidence,
3117 ..
3118 } = output;
3119 if let Err(err) = write_chat_prompt_token_evidence(
3120 state.request_dump_dir.as_ref().map(|root| root.as_path()),
3121 &replay_request_id,
3122 &profile_request_model,
3123 execution_evidence.as_ref(),
3124 ) {
3125 warn!("failed to write chat prompt-token evidence: {}", err);
3126 }
3127
3128 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
3132 let content = strip_after_stop(&output_text, &stop_sequences);
3133 let native_projected = native_projector.take().map(|mut projector| {
3134 projector.push(&content);
3135 projector.finish(finish_reason)
3136 });
3137 let parsed_model_output = if let Some(projected) = native_projected.as_ref() {
3138 ParsedChatModelOutput {
3139 visible: projected.visible.clone(),
3140 harmony_response: None,
3141 }
3142 } else {
3143 parse_chat_model_output(
3144 model_output_protocol,
3145 &content,
3146 started_in_think,
3147 finish_reason,
3148 )?
3149 };
3150 let parsed = parsed_model_output.visible;
3151 let visible_content =
3152 normalize_structured_response_content(&openai_request, &parsed.content);
3153 let mut message = ChatMessage {
3154 role: MessageRole::Assistant,
3155 content: visible_content,
3156 reasoning: parsed.reasoning.clone(),
3157 name: None,
3158 tool_calls: None,
3159 tool_call_id: None,
3160 function_call: None,
3161 };
3162 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
3163 let mut structured_chat_response =
3164 finish_reason_allows_structured_api_response(finish_reason)
3165 .then(|| match api_response.as_ref() {
3166 _ if model_output_protocol == ModelOutputProtocol::HarmonyGptOss => {
3169 parsed_model_output.harmony_response.clone()
3170 }
3171 Some(ferrum_types::ApiResponse::Chat(chat_response)) => {
3172 Some(chat_response.clone())
3173 }
3174 _ if native_projected.is_some() => native_projected
3175 .as_ref()
3176 .and_then(|projected| projected.api_response.clone()),
3177 _ => match request_chat_api.as_ref() {
3178 Some(chat_request) => chat_api_response_from_parsed_generated_text(
3179 chat_request,
3180 &parsed,
3181 finish_reason,
3182 ),
3183 _ => None,
3184 },
3185 })
3186 .flatten();
3187 if native_projected.is_none()
3188 && matches!(api_response, Some(ferrum_types::ApiResponse::Chat(_)))
3189 {
3190 if let Some(response) = structured_chat_response.as_mut() {
3191 project_typed_tool_response_content(
3192 response,
3193 model_output_protocol,
3194 started_in_think,
3195 )?;
3196 }
3197 }
3198 if let Some(chat_response) = structured_chat_response.as_ref() {
3199 if let Err(error) =
3200 validate_structured_tool_response(&openai_request, chat_response)
3201 {
3202 if let Err(err) = write_chat_request_profile_event(
3203 &state,
3204 &replay_request_id,
3205 benchmark_correlation.as_ref(),
3206 &profile_request_model,
3207 false,
3208 "chat_completions_sync_tool_contract",
3209 profile_started_at,
3210 ChatRequestProfileTiming {
3211 engine_evidence: execution_evidence.as_ref(),
3212 ..Default::default()
3213 },
3214 tokens.len(),
3215 Some(&usage),
3216 Some("error"),
3217 Some(ProfileError {
3218 kind: "tool_contract_failure".to_string(),
3219 message: format!("{error:?}"),
3220 blocking: true,
3221 }),
3222 ) {
3223 warn!("failed to write chat tool-contract profile event: {}", err);
3224 }
3225 return Err(error);
3226 }
3227 message = openai_chat_message_from_api(&chat_response.message);
3228 if message.reasoning.is_none() {
3229 message.reasoning = parsed.reasoning.clone();
3230 }
3231 if let Some(reason) = &chat_response.finish_reason {
3232 openai_finish_reason = reason.clone();
3233 }
3234 } else if tool_choice_required(&openai_request) {
3235 log_required_tool_choice_failure(
3236 &openai_request,
3237 &parsed.content,
3238 parsed.reasoning.as_deref(),
3239 );
3240 if let Err(err) = write_chat_request_profile_event(
3241 &state,
3242 &replay_request_id,
3243 benchmark_correlation.as_ref(),
3244 &profile_request_model,
3245 false,
3246 "chat_completions_sync_tool_choice",
3247 profile_started_at,
3248 ChatRequestProfileTiming {
3249 engine_evidence: execution_evidence.as_ref(),
3250 ..Default::default()
3251 },
3252 tokens.len(),
3253 Some(&usage),
3254 Some("error"),
3255 Some(ProfileError {
3256 kind: "required_tool_failure".to_string(),
3257 message: "model output did not satisfy required tool_choice".to_string(),
3258 blocking: true,
3259 }),
3260 ) {
3261 warn!("failed to write chat tool-choice profile event: {}", err);
3262 }
3263 return Err(ServerError::invalid_request(
3264 "model output did not satisfy required tool_choice",
3265 Some("tool_choice"),
3266 ));
3267 }
3268 if let Err(error) = validate_hard_structured_response(
3269 &openai_request,
3270 &message.content,
3271 structured_chat_response.as_ref(),
3272 ) {
3273 if let Err(err) = write_chat_request_profile_event(
3274 &state,
3275 &replay_request_id,
3276 benchmark_correlation.as_ref(),
3277 &profile_request_model,
3278 false,
3279 "chat_completions_sync_structured_output",
3280 profile_started_at,
3281 ChatRequestProfileTiming {
3282 engine_evidence: execution_evidence.as_ref(),
3283 ..Default::default()
3284 },
3285 tokens.len(),
3286 Some(&usage),
3287 Some("error"),
3288 Some(ProfileError {
3289 kind: "structured_output_failure".to_string(),
3290 message: format!("{error:?}"),
3291 blocking: true,
3292 }),
3293 ) {
3294 warn!("failed to write chat strict-schema profile event: {}", err);
3295 }
3296 return Err(error);
3297 }
3298 if let Err(err) = write_chat_request_completion_replay_bundle(
3299 state.request_dump_dir.as_ref().map(|root| root.as_path()),
3300 &replay_request_id,
3301 &message.content,
3302 &tokens,
3303 Some(&openai_finish_reason),
3304 ) {
3305 warn!("failed to write chat completion replay bundle: {}", err);
3306 }
3307 if let Err(err) = write_chat_request_profile_event(
3308 &state,
3309 &replay_request_id,
3310 benchmark_correlation.as_ref(),
3311 &profile_request_model,
3312 false,
3313 "chat_completions_sync_complete",
3314 profile_started_at,
3315 ChatRequestProfileTiming {
3316 engine_evidence: execution_evidence.as_ref(),
3317 ..Default::default()
3318 },
3319 tokens.len(),
3320 Some(&usage),
3321 Some(&openai_finish_reason),
3322 None,
3323 ) {
3324 warn!("failed to write chat sync profile event: {}", err);
3325 }
3326 if let Err(err) = maybe_write_first_request_memory_stage(
3327 &state,
3328 &replay_request_id,
3329 benchmark_correlation.as_ref(),
3330 &profile_request_model,
3331 false,
3332 profile_started_at,
3333 request_memory_before,
3334 ) {
3335 warn!("failed to write chat sync memory profile event: {}", err);
3336 }
3337 state
3338 .cache
3339 .update_session(session_context, message.clone(), &CachePolicy::current());
3340 let response = ChatCompletionsResponse {
3341 id: replay_request_id,
3342 object: "chat.completion".to_string(),
3343 created: chrono::Utc::now().timestamp() as u64,
3344 model: openai_request.model,
3345 choices: vec![ChatChoice {
3346 index: 0,
3347 message: Some(message),
3348 delta: None,
3349 finish_reason: Some(openai_finish_reason),
3350 }],
3351 usage: Some(openai_usage_from_token_usage(&usage)),
3352 };
3353
3354 Ok(Json(response).into_response())
3355 }
3356 Err(e) => {
3357 let failure_kind = e.observability_failure_kind();
3358 let error_kind = e.observability_error_kind();
3359 let error_message = e.to_string();
3360 let engine_status = if chat_resource_failure_kind(failure_kind) {
3361 Some(engine.status().await)
3362 } else {
3363 None
3364 };
3365 error!("Generation failed: {}", error_message);
3366 if let Err(err) = write_chat_request_profile_event(
3367 &state,
3368 &replay_request_id,
3369 benchmark_correlation.as_ref(),
3370 &profile_request_model,
3371 false,
3372 "chat_completions_sync",
3373 profile_started_at,
3374 ChatRequestProfileTiming::default(),
3375 0,
3376 None,
3377 Some("error"),
3378 Some(ProfileError {
3379 kind: error_kind.to_string(),
3380 message: error_message.clone(),
3381 blocking: false,
3382 }),
3383 ) {
3384 warn!("failed to write chat sync failure profile event: {}", err);
3385 }
3386 if let Err(err) = write_chat_request_failure_diagnostics(
3387 &state,
3388 &replay_request_id,
3389 failure_kind,
3390 "chat_completions_sync",
3391 error_kind,
3392 &error_message,
3393 engine_status.as_ref(),
3394 ) {
3395 warn!(
3396 "failed to write chat generation failure diagnostics: {}",
3397 err
3398 );
3399 }
3400 Err(server_error_from_ferrum_error(e))
3401 }
3402 }
3403}
3404
3405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3406enum EffectiveChatOutputContract {
3407 RequiredToolCall,
3408 StrictJsonSchemaContent,
3409 JsonObjectContent,
3410 BestEffortJsonSchemaContent,
3411 Text,
3412}
3413
3414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3415enum ChatOutputBudget {
3416 AutoCeiling(u32),
3417 Explicit(u32),
3418}
3419
3420impl ChatOutputBudget {
3421 fn resolve(request: &ChatCompletionsRequest) -> Self {
3422 request
3423 .max_completion_tokens
3424 .or(request.max_tokens)
3425 .map(Self::Explicit)
3426 .unwrap_or(Self::AutoCeiling(DEFAULT_COMPLETION_MAX_TOKENS))
3427 }
3428
3429 const fn ceiling(self) -> u32 {
3430 match self {
3431 Self::AutoCeiling(value) | Self::Explicit(value) => value,
3432 }
3433 }
3434
3435 const fn is_auto(self) -> bool {
3436 matches!(self, Self::AutoCeiling(_))
3437 }
3438}
3439
3440impl EffectiveChatOutputContract {
3441 fn resolve(request: &ChatCompletionsRequest) -> Self {
3442 if tool_choice_required(request) {
3443 return Self::RequiredToolCall;
3444 }
3445 let Some(format) = request.response_format.as_ref() else {
3446 return Self::Text;
3447 };
3448 match format.format_type.as_str() {
3449 "json_schema"
3450 if format
3451 .json_schema
3452 .as_ref()
3453 .and_then(|schema| schema.strict)
3454 .unwrap_or(false) =>
3455 {
3456 Self::StrictJsonSchemaContent
3457 }
3458 "json_schema" => Self::BestEffortJsonSchemaContent,
3459 "json_object" => Self::JsonObjectContent,
3460 _ => Self::Text,
3461 }
3462 }
3463
3464 fn accepts_requested_response_format(self) -> bool {
3465 !matches!(self, Self::RequiredToolCall)
3466 }
3467}
3468
3469#[allow(dead_code)]
3471fn convert_chat_request(
3472 request: &ChatCompletionsRequest,
3473) -> ferrum_types::Result<InferenceRequest> {
3474 convert_chat_request_with_template_model(request, &request.model, None)
3475}
3476
3477fn request_started_in_reasoning(request: &InferenceRequest) -> bool {
3478 request
3479 .metadata
3480 .get(PROMPT_OPENED_REASONING_METADATA_KEY)
3481 .and_then(serde_json::Value::as_bool)
3482 .unwrap_or_else(|| {
3483 has_unclosed_model_reasoning_block(
3487 request.sampling_params.model_output_protocol,
3488 &request.prompt,
3489 )
3490 })
3491}
3492
3493fn convert_chat_request_with_template_model(
3500 request: &ChatCompletionsRequest,
3501 template_model_id: &str,
3502 model_template: Option<&ModelChatTemplate>,
3503) -> ferrum_types::Result<InferenceRequest> {
3504 convert_chat_request_with_template_model_and_default(
3505 request,
3506 template_model_id,
3507 model_template,
3508 None,
3509 true,
3510 None,
3511 )
3512}
3513
3514fn convert_chat_request_with_template_model_and_default(
3515 request: &ChatCompletionsRequest,
3516 template_model_id: &str,
3517 model_template: Option<&ModelChatTemplate>,
3518 default_enable_thinking: Option<bool>,
3519 interleaved_system_coalescing: bool,
3520 message_phases: Option<&[Option<AssistantMessagePhase>]>,
3521) -> ferrum_types::Result<InferenceRequest> {
3522 let no_tools: &[ChatTool] = &[];
3523 let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
3524 no_tools
3525 } else {
3526 request.tools.as_deref().unwrap_or_default()
3527 };
3528 let default_tool_choice =
3529 default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
3530 let effective_tool_choice = request
3531 .tool_choice
3532 .as_ref()
3533 .or(default_tool_choice.as_ref());
3534 let functions = request.functions.as_deref().unwrap_or_default();
3535 let model_output_protocol = model_template
3536 .map(|template| template.output_protocol)
3537 .unwrap_or(ModelOutputProtocol::Text);
3538 let output_contract = EffectiveChatOutputContract::resolve(request);
3539 let output_budget = ChatOutputBudget::resolve(request);
3540 let tool_call_protocol = model_template
3541 .map(|template| {
3542 if model_output_protocol == ModelOutputProtocol::HarmonyGptOss
3545 && template.tool_call_protocol == ferrum_types::ApiToolCallProtocol::NativeJson
3546 {
3547 ferrum_types::ApiToolCallProtocol::Json
3548 } else {
3549 template.tool_call_protocol
3550 }
3551 })
3552 .unwrap_or_default();
3553 let api_chat = api_chat_request(request, effective_tool_choice, tool_call_protocol);
3554 let native_tool_call_contract = api_chat.requires_native_tool_call();
3555 let forced_response_format = (model_output_protocol != ModelOutputProtocol::HarmonyGptOss
3559 && !native_tool_call_contract)
3560 .then(|| forced_tool_choice_response_format(request))
3561 .flatten();
3562 let hard_tool_call_contract = forced_response_format.is_some() || native_tool_call_contract;
3563 let requested_response_format = output_contract
3564 .accepts_requested_response_format()
3565 .then(|| requested_response_format_for_sampling(request))
3566 .transpose()?
3567 .flatten();
3568 let chat_template_options =
3569 chat_template_options_for_request(request, model_template, default_enable_thinking)?;
3570 let response_format = forced_response_format
3571 .or(requested_response_format)
3572 .unwrap_or(ferrum_types::ResponseFormat::Text);
3573 let model_generated_thinking = model_template.is_some_and(|template| {
3574 template.reasoning_protocol == ModelReasoningProtocol::ModelGenerated
3575 && template.reasoning_enabled(chat_template_options.enable_thinking)
3576 });
3577 let reasoning_enabled = model_template
3578 .is_some_and(|template| template.reasoning_enabled(chat_template_options.enable_thinking));
3579 let (render_messages, render_message_phases) = render_messages_with_response_format_instruction(
3580 request,
3581 output_contract,
3582 reasoning_enabled,
3583 model_template,
3584 message_phases,
3585 );
3586 let rendered_prompt = if tools.is_empty() && functions.is_empty() {
3587 render_chat_prompt_with_model_template_options_and_compatibility_with_prefill(
3588 &render_messages,
3589 template_model_id,
3590 model_template,
3591 &chat_template_options,
3592 interleaved_system_coalescing,
3593 Some(&render_message_phases),
3594 )?
3595 } else {
3596 render_chat_prompt_with_tools_and_model_template_compatibility_with_prefill(
3597 &render_messages,
3598 template_model_id,
3599 model_template,
3600 &chat_template_options,
3601 tools,
3602 effective_tool_choice,
3603 functions,
3604 request.function_call.as_ref(),
3605 interleaved_system_coalescing,
3606 Some(&render_message_phases),
3607 )?
3608 };
3609 let prompt = rendered_prompt.text;
3610 let prompt_opened_thinking = rendered_prompt.reasoning_prefill;
3611 let mut metadata = HashMap::new();
3612 metadata.insert(
3613 PROMPT_OPENED_REASONING_METADATA_KEY.to_string(),
3614 serde_json::Value::Bool(prompt_opened_thinking),
3615 );
3616 metadata.insert(
3617 "openai_messages".to_string(),
3618 serde_json::to_value(&request.messages)?,
3619 );
3620 if let Some(tools) = &request.tools {
3621 metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
3622 }
3623 if let Some(tool_choice) = effective_tool_choice {
3624 metadata.insert(
3625 "openai_tool_choice".to_string(),
3626 serde_json::to_value(tool_choice)?,
3627 );
3628 }
3629 if let Some(functions) = &request.functions {
3630 metadata.insert(
3631 "openai_legacy_functions".to_string(),
3632 serde_json::to_value(functions)?,
3633 );
3634 }
3635 if let Some(function_call) = &request.function_call {
3636 metadata.insert(
3637 "openai_legacy_function_call".to_string(),
3638 serde_json::to_value(function_call)?,
3639 );
3640 }
3641 if request.ignore_eos.unwrap_or(false) {
3642 metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
3643 }
3644 if output_budget.is_auto() {
3645 metadata.insert(
3646 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
3647 serde_json::json!(true),
3648 );
3649 }
3650 let reasoning_markers = model_reasoning_markers(model_output_protocol);
3651 if !prompt_opened_thinking {
3652 let mut forbidden = reasoning_markers
3653 .map(|(_, close)| vec![close.to_string()])
3654 .unwrap_or_default();
3655 if hard_tool_call_contract {
3656 for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
3657 push_unique_forbidden_token_text(&mut forbidden, token_text);
3658 }
3659 if let Some(eos) = model_template.as_ref().and_then(|template| {
3660 template
3661 .eos_token
3662 .as_deref()
3663 .filter(|token| !token.is_empty())
3664 }) {
3665 push_unique_forbidden_token_text(&mut forbidden, eos);
3666 }
3667 }
3668 if model_output_protocol == ModelOutputProtocol::Text
3669 && chat_template_options.enable_thinking == Some(false)
3670 && model_template
3671 .is_some_and(|template| template.reasoning_protocol.supports_reasoning())
3672 {
3673 push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
3674 }
3675 metadata.insert(
3676 INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
3677 serde_json::json!(forbidden),
3678 );
3679 }
3680 let structured_output =
3681 !matches!(response_format, ferrum_types::ResponseFormat::Text) || native_tool_call_contract;
3682 let structured_output_after_reasoning = structured_output
3683 && model_output_protocol == ModelOutputProtocol::Text
3684 && (prompt_opened_thinking || model_generated_thinking);
3685 let structured_output_start =
3686 if structured_output && model_output_protocol == ModelOutputProtocol::HarmonyGptOss {
3687 StructuredOutputStart::HarmonyFinal
3688 } else if structured_output && model_output_protocol == ModelOutputProtocol::GemmaThought {
3689 let (opening, closing) = reasoning_markers.expect("Gemma thought markers");
3690 if prompt_opened_thinking {
3691 StructuredOutputStart::AfterDelimiter(closing.to_string())
3692 } else if prompt.trim_end().ends_with(closing) {
3693 StructuredOutputStart::Immediate
3694 } else {
3695 StructuredOutputStart::AfterReasoningEnvelope {
3696 opening: opening.to_string(),
3697 closing: closing.to_string(),
3698 allow_reasoning: reasoning_enabled,
3699 }
3700 }
3701 } else if structured_output_after_reasoning {
3702 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
3703 } else {
3704 StructuredOutputStart::Immediate
3705 };
3706 let delayed_grammar = matches!(
3707 structured_output_start,
3708 StructuredOutputStart::AfterDelimiter(_)
3709 | StructuredOutputStart::AfterReasoningEnvelope { .. }
3710 );
3711 let response_completion_boundary = if let Some((_, closing)) =
3712 reasoning_markers.filter(|_| prompt_opened_thinking || delayed_grammar)
3713 {
3714 ResponseCompletionBoundary::AfterDelimiterAndPayload {
3715 delimiter: closing.to_string(),
3716 alternate_envelope: api_chat.generated_response_envelope(),
3717 }
3718 } else {
3719 ResponseCompletionBoundary::Immediate
3720 };
3721
3722 Ok(InferenceRequest {
3723 id: RequestId(Uuid::new_v4()),
3724 model_id: ModelId(request.model.clone()),
3725 prompt,
3726 sampling_params: SamplingParams {
3727 max_tokens: output_budget.ceiling() as usize,
3728 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
3729 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
3730 top_k: request
3731 .top_k
3732 .filter(|value| *value > 0)
3733 .and_then(|value| usize::try_from(value).ok()),
3734 repetition_penalty: request
3735 .repetition_penalty
3736 .unwrap_or(DEFAULT_CHAT_REPETITION_PENALTY),
3737 presence_penalty: request.presence_penalty.unwrap_or(0.0),
3738 frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
3739 stop_sequences: request.stop.clone().unwrap_or_default(),
3740 seed: request.seed,
3741 min_p: request.min_p.filter(|value| *value > 0.0),
3742 tfs: None,
3743 typical_p: None,
3744 mirostat: None,
3745 response_format,
3746 structured_output_start,
3747 response_completion_boundary,
3748 model_output_protocol,
3749 },
3750 stream: request.stream.unwrap_or(false),
3751 priority: Priority::Normal, client_id: None,
3753 session_id: None,
3754 created_at: chrono::Utc::now(),
3755 api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
3756 evidence_request: Default::default(),
3757 metadata,
3758 })
3759}
3760
3761fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
3762 if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
3763 tokens.push(token.to_string());
3764 }
3765}
3766
3767fn default_auto_tool_choice_for_tools(
3768 tools: &[ChatTool],
3769 choice: Option<&ToolChoice>,
3770) -> Option<ToolChoice> {
3771 if choice.is_none() && !tools.is_empty() {
3772 Some(ToolChoice::Mode("auto".to_string()))
3773 } else {
3774 None
3775 }
3776}
3777
3778fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
3779 matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
3780}
3781
3782fn tool_choice_none_hides_tools(
3783 choice: Option<&ToolChoice>,
3784 model_template: Option<&ModelChatTemplate>,
3785) -> bool {
3786 tool_choice_none(choice)
3787 && model_template
3788 .map(|template| template.template.contains("tools_in_user_message"))
3789 .unwrap_or(false)
3790}
3791
3792fn chat_template_options_for_request(
3793 request: &ChatCompletionsRequest,
3794 model_template: Option<&ModelChatTemplate>,
3795 default_enable_thinking: Option<bool>,
3796) -> ferrum_types::Result<ChatTemplateOptions> {
3797 let mut options = ChatTemplateOptions::default_for_template(model_template);
3798 let kwargs = request.chat_template_kwargs.as_ref();
3799 let explicit_thinking = kwargs
3800 .and_then(|values| values.get("enable_thinking"))
3801 .filter(|value| !value.is_null())
3802 .map(|value| {
3803 value.as_bool().ok_or_else(|| {
3804 Error::invalid_request("chat_template_kwargs.enable_thinking must be a boolean")
3805 })
3806 })
3807 .transpose()?;
3808 let extension_effort = kwargs
3809 .and_then(|values| values.get("reasoning_effort"))
3810 .filter(|value| !value.is_null())
3811 .map(|value| {
3812 serde_json::from_value::<ReasoningEffort>(value.clone()).map_err(|error| {
3813 Error::invalid_request(format!("chat_template_kwargs.reasoning_effort: {error}"))
3814 })
3815 })
3816 .transpose()?;
3817 if let (Some(standard), Some(extension)) = (request.reasoning_effort, extension_effort) {
3818 if standard != extension {
3819 return Err(Error::invalid_request(
3820 "reasoning_effort conflicts with chat_template_kwargs.reasoning_effort",
3821 ));
3822 }
3823 }
3824 options.reasoning_effort = request.reasoning_effort.or(extension_effort);
3825 let effort_thinking = request
3826 .reasoning_effort
3827 .map(|effort| effort != ReasoningEffort::None);
3828 if let (Some(explicit), Some(derived)) = (explicit_thinking, effort_thinking) {
3829 if explicit != derived {
3830 return Err(Error::invalid_request(
3831 "reasoning_effort conflicts with chat_template_kwargs.enable_thinking",
3832 ));
3833 }
3834 }
3835 options.enable_thinking = explicit_thinking
3839 .or(effort_thinking)
3840 .or(default_enable_thinking);
3841 if let (Some(template), Some(effort)) = (model_template, request.reasoning_effort) {
3842 template.validate_reasoning_effort(effort)?;
3843 }
3844 Ok(options)
3845}
3846
3847fn render_messages_with_response_format_instruction(
3848 request: &ChatCompletionsRequest,
3849 output_contract: EffectiveChatOutputContract,
3850 reasoning_enabled: bool,
3851 model_template: Option<&ModelChatTemplate>,
3852 message_phases: Option<&[Option<AssistantMessagePhase>]>,
3853) -> (Vec<ChatMessage>, Vec<Option<AssistantMessagePhase>>) {
3854 let mut phases = message_phases
3855 .map(ToOwned::to_owned)
3856 .unwrap_or_else(|| vec![None; request.messages.len()]);
3857 debug_assert_eq!(phases.len(), request.messages.len());
3858 let Some(instruction) = response_format_prompt_instruction(
3859 request,
3860 output_contract,
3861 reasoning_enabled,
3862 model_template,
3863 ) else {
3864 return (request.messages.clone(), phases);
3865 };
3866 let mut messages = request.messages.clone();
3867 let leading_systems = messages
3868 .iter()
3869 .take_while(|message| message.role == MessageRole::System)
3870 .count();
3871 let mut system_parts = Vec::with_capacity(leading_systems + 1);
3872 system_parts.push(instruction);
3873 system_parts.extend(
3874 messages
3875 .drain(..leading_systems)
3876 .map(|message| message.content)
3877 .filter(|content| !content.is_empty()),
3878 );
3879 phases.drain(..leading_systems);
3880 messages.insert(
3881 0,
3882 ChatMessage {
3883 role: MessageRole::System,
3884 content: system_parts.join("\n\n"),
3885 reasoning: None,
3886 name: None,
3887 tool_calls: None,
3888 tool_call_id: None,
3889 function_call: None,
3890 },
3891 );
3892 phases.insert(0, None);
3893 (messages, phases)
3894}
3895
3896fn response_format_prompt_instruction(
3897 request: &ChatCompletionsRequest,
3898 output_contract: EffectiveChatOutputContract,
3899 reasoning_enabled: bool,
3900 model_template: Option<&ModelChatTemplate>,
3901) -> Option<String> {
3902 if !output_contract.accepts_requested_response_format() {
3903 return None;
3904 }
3905 let automatic_tools = request
3906 .tools
3907 .as_ref()
3908 .is_some_and(|tools| !tools.is_empty())
3909 && match request.tool_choice.as_ref() {
3910 None => true,
3911 Some(ToolChoice::Mode(mode)) => mode.eq_ignore_ascii_case("auto"),
3912 _ => false,
3913 }
3914 && matches!(
3915 output_contract,
3916 EffectiveChatOutputContract::StrictJsonSchemaContent
3917 | EffectiveChatOutputContract::JsonObjectContent
3918 );
3919 let native_tool_template =
3920 model_template.is_some_and(crate::chat_template::model_template_supports_tools);
3921 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.";
3922 if let Some(format) = request.response_format.as_ref() {
3923 return match format.format_type.as_str() {
3924 "json_object" if automatic_tools && native_tool_template => {
3925 Some(native_tool_final_format_instruction(r#"{"type":"object"}"#))
3926 }
3927 "json_object" if automatic_tools => Some(format!(
3928 "{tool_instruction} The response_format applies only to the final answer: output a single valid JSON object, with no markdown fences or extra text."
3929 )),
3930 "json_object" => Some(
3931 "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."
3932 .to_string(),
3933 ),
3934 "json_schema" => {
3935 let schema = format.json_schema.as_ref()?.schema.as_ref()?;
3936 let schema_text = serde_json::to_string(schema).ok()?;
3937 Some(if automatic_tools && native_tool_template {
3938 native_tool_final_format_instruction(&schema_text)
3939 } else if automatic_tools {
3940 format!(
3941 "{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}"
3942 )
3943 } else if reasoning_enabled {
3944 format!(
3945 "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}"
3946 )
3947 } else {
3948 format!(
3949 "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}"
3950 )
3951 })
3952 }
3953 _ => None,
3954 };
3955 }
3956 None
3957}
3958
3959fn native_tool_final_format_instruction(schema: &str) -> String {
3960 format!(
3964 "# 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."
3965 )
3966}
3967
3968fn forced_tool_choice_response_format(
3969 request: &ChatCompletionsRequest,
3970) -> Option<ferrum_types::ResponseFormat> {
3971 let selected_tool = selected_tool_for_forced_tool_choice(request)?;
3972 let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
3973 serde_json::to_string(&schema)
3974 .ok()
3975 .map(ferrum_types::ResponseFormat::JsonSchema)
3976}
3977
3978fn requested_response_format_for_sampling(
3979 request: &ChatCompletionsRequest,
3980) -> ferrum_types::Result<Option<ferrum_types::ResponseFormat>> {
3981 let Some(format) = request.response_format.as_ref() else {
3982 return Ok(None);
3983 };
3984 match format.format_type.as_str() {
3985 "json_object" => Ok(Some(ferrum_types::ResponseFormat::JsonObject)),
3986 "json_schema" => {
3987 let Some(schema) = format.json_schema.as_ref() else {
3988 return Err(Error::invalid_request(
3989 "response_format.json_schema.schema is required",
3990 ));
3991 };
3992 if !schema.strict.unwrap_or(false) {
3993 return Ok(None);
3994 }
3995 let Some(schema_value) = schema.schema.as_ref() else {
3996 return Err(Error::invalid_request(
3997 "response_format.json_schema.schema is required",
3998 ));
3999 };
4000 serde_json::to_string(schema_value)
4001 .map(|schema| Some(ferrum_types::ResponseFormat::JsonSchema(schema)))
4002 .map_err(|err| Error::invalid_request(err.to_string()))
4003 }
4004 _ => Ok(None),
4005 }
4006}
4007
4008fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
4009 match request.tool_choice.as_ref()? {
4010 ToolChoice::Function {
4011 tool_type,
4012 function,
4013 } if tool_type == "function" => request
4014 .tools
4015 .as_ref()?
4016 .iter()
4017 .find(|tool| tool.function.name == function.name),
4018 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
4019 single_function_tool(request.tools.as_deref()?)
4020 }
4021 _ => None,
4022 }
4023}
4024
4025fn guided_tool_arguments_schema(
4026 parameters: Option<&serde_json::Value>,
4027) -> Option<serde_json::Value> {
4028 let mut schema = parameters?.clone();
4029 bound_unconstrained_tool_argument_strings(
4030 &mut schema,
4031 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
4032 );
4033 Some(schema)
4034}
4035
4036fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
4037 match value {
4038 serde_json::Value::Object(map) => {
4039 let is_string = map
4040 .get("type")
4041 .and_then(serde_json::Value::as_str)
4042 .is_some_and(|ty| ty == "string");
4043 let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
4044 if is_string && !has_finite_string_shape {
4045 map.insert(
4046 "maxLength".to_string(),
4047 serde_json::Value::Number(default_max.into()),
4048 );
4049 }
4050 if let Some(properties) = map
4051 .get_mut("properties")
4052 .and_then(serde_json::Value::as_object_mut)
4053 {
4054 for property in properties.values_mut() {
4055 bound_unconstrained_tool_argument_strings(property, default_max);
4056 }
4057 }
4058 if let Some(items) = map.get_mut("items") {
4059 bound_unconstrained_tool_argument_strings(items, default_max);
4060 }
4061 }
4062 serde_json::Value::Array(items) => {
4063 for item in items {
4064 bound_unconstrained_tool_argument_strings(item, default_max);
4065 }
4066 }
4067 _ => {}
4068 }
4069}
4070
4071fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
4072 let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
4073 let tool = function_tools.next()?;
4074 function_tools.next().is_none().then_some(tool)
4075}
4076
4077fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
4078 if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
4079 let delta = text[*sent_len..].to_string();
4080 *sent_len = text.len();
4081 return delta;
4082 }
4083 *sent_len = text.len();
4084 String::new()
4085}
4086
4087fn project_typed_tool_response_content(
4088 response: &mut ferrum_types::ApiChatResponse,
4089 protocol: ModelOutputProtocol,
4090 started_in_think: bool,
4091) -> std::result::Result<(), ServerError> {
4092 if protocol == ModelOutputProtocol::HarmonyGptOss
4093 || response.message.tool_calls.is_empty()
4094 || response.message.content.is_empty()
4095 {
4096 return Ok(());
4097 }
4098 response.message.content =
4102 parse_model_reasoning_response(protocol, &response.message.content, started_in_think)
4103 .map_err(|error| ServerError::InternalError(error.to_string()))?
4104 .content;
4105 Ok(())
4106}
4107
4108fn chat_api_response_from_parsed_generated_text(
4109 chat_request: &ferrum_types::ApiChatRequest,
4110 parsed: &ParsedReasoningResponse,
4111 finish_reason: FinishReason,
4112) -> Option<ferrum_types::ApiChatResponse> {
4113 parsed
4114 .reasoning
4115 .as_deref()
4116 .and_then(|reasoning| {
4117 ferrum_types::chat_api_response_from_generated_text(
4118 chat_request,
4119 reasoning,
4120 finish_reason,
4121 )
4122 })
4123 .map(|mut response| {
4124 response.message.content.clear();
4127 response
4128 })
4129 .or_else(|| {
4130 ferrum_types::chat_api_response_from_generated_text(
4131 chat_request,
4132 &parsed.content,
4133 finish_reason,
4134 )
4135 })
4136}
4137
4138fn finish_reason_allows_structured_api_response(finish_reason: FinishReason) -> bool {
4139 matches!(finish_reason, FinishReason::Stop | FinishReason::EOS)
4140}
4141
4142fn log_required_tool_choice_failure(
4143 request: &ChatCompletionsRequest,
4144 content: &str,
4145 reasoning: Option<&str>,
4146) {
4147 warn!(
4148 model = %request.model,
4149 content_len = content.len(),
4150 content_head = %log_excerpt(content, 512),
4151 reasoning_len = reasoning.map(str::len).unwrap_or(0),
4152 reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
4153 "model output did not satisfy required tool_choice"
4154 );
4155}
4156
4157fn log_excerpt(value: &str, max_chars: usize) -> String {
4158 let mut out = value.chars().take(max_chars).collect::<String>();
4159 if value.chars().count() > max_chars {
4160 out.push_str("...");
4161 }
4162 out
4163}
4164
4165fn normalize_structured_response_content(
4166 request: &ChatCompletionsRequest,
4167 content: &str,
4168) -> String {
4169 match EffectiveChatOutputContract::resolve(request) {
4170 EffectiveChatOutputContract::BestEffortJsonSchemaContent => {
4171 extract_json_object_text(content)
4172 .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
4173 }
4174 EffectiveChatOutputContract::RequiredToolCall
4175 | EffectiveChatOutputContract::StrictJsonSchemaContent
4176 | EffectiveChatOutputContract::JsonObjectContent
4177 | EffectiveChatOutputContract::Text => content.to_string(),
4178 }
4179}
4180
4181fn extract_json_object_text(text: &str) -> Option<String> {
4182 let text = strip_markdown_json_fence(text.trim());
4183 if serde_json::from_str::<serde_json::Value>(&text)
4184 .ok()
4185 .filter(|value| value.is_object())
4186 .is_some()
4187 {
4188 return Some(text.to_string());
4189 }
4190
4191 let start = text.find('{')?;
4192 let mut depth = 0usize;
4193 let mut in_string = false;
4194 let mut escaped = false;
4195 for (offset, ch) in text[start..].char_indices() {
4196 if in_string {
4197 if escaped {
4198 escaped = false;
4199 } else if ch == '\\' {
4200 escaped = true;
4201 } else if ch == '"' {
4202 in_string = false;
4203 }
4204 continue;
4205 }
4206 match ch {
4207 '"' => in_string = true,
4208 '{' => depth += 1,
4209 '}' => {
4210 depth = depth.saturating_sub(1);
4211 if depth == 0 {
4212 let end = start + offset + ch.len_utf8();
4213 let candidate = &text[start..end];
4214 if serde_json::from_str::<serde_json::Value>(candidate)
4215 .ok()
4216 .filter(|value| value.is_object())
4217 .is_some()
4218 {
4219 return Some(candidate.to_string());
4220 }
4221 }
4222 }
4223 _ => {}
4224 }
4225 }
4226 None
4227}
4228
4229fn api_chat_request(
4230 request: &ChatCompletionsRequest,
4231 effective_tool_choice: Option<&ToolChoice>,
4232 tool_call_protocol: ferrum_types::ApiToolCallProtocol,
4233) -> ferrum_types::ApiChatRequest {
4234 ferrum_types::ApiChatRequest {
4235 messages: request.messages.iter().map(api_chat_message).collect(),
4236 tools: request
4237 .tools
4238 .as_deref()
4239 .unwrap_or_default()
4240 .iter()
4241 .map(api_tool)
4242 .collect(),
4243 tool_choice: effective_tool_choice.map(api_tool_choice),
4244 tool_call_protocol,
4245 legacy_functions: request
4246 .functions
4247 .as_deref()
4248 .unwrap_or_default()
4249 .iter()
4250 .map(api_function)
4251 .collect(),
4252 legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
4253 response_format: request.response_format.as_ref().map(api_response_format),
4254 stream_options: request.stream_options.as_ref().map(|opts| {
4255 ferrum_types::ApiStreamOptions {
4256 include_usage: opts.include_usage,
4257 }
4258 }),
4259 }
4260}
4261
4262fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
4263 ferrum_types::ApiChatMessage {
4264 role: match message.role {
4265 MessageRole::System => ferrum_types::ApiMessageRole::System,
4266 MessageRole::User => ferrum_types::ApiMessageRole::User,
4267 MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
4268 MessageRole::Function => ferrum_types::ApiMessageRole::Function,
4269 MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
4270 },
4271 content: message.content.clone(),
4272 name: message.name.clone(),
4273 tool_calls: message
4274 .tool_calls
4275 .as_deref()
4276 .unwrap_or_default()
4277 .iter()
4278 .map(api_tool_call)
4279 .collect(),
4280 tool_call_id: message.tool_call_id.clone(),
4281 function_call: message.function_call.as_ref().map(api_function_call),
4282 }
4283}
4284
4285fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
4286 ferrum_types::ApiTool {
4287 tool_type: tool.tool_type.clone(),
4288 function: api_function(&tool.function),
4289 }
4290}
4291
4292fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
4293 ferrum_types::ApiFunction {
4294 name: function.name.clone(),
4295 description: function.description.clone(),
4296 parameters: function.parameters.clone(),
4297 strict: function.strict,
4298 }
4299}
4300
4301fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
4302 match choice {
4303 ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
4304 ToolChoice::Function {
4305 tool_type,
4306 function,
4307 } => ferrum_types::ApiToolChoice::Function {
4308 tool_type: tool_type.clone(),
4309 function: ferrum_types::ApiToolChoiceFunction {
4310 name: function.name.clone(),
4311 },
4312 },
4313 }
4314}
4315
4316fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
4317 match choice {
4318 FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
4319 FunctionCallChoice::Function { name } => {
4320 ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
4321 }
4322 }
4323}
4324
4325fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
4326 ferrum_types::ApiToolCall {
4327 id: tool_call.id.clone(),
4328 tool_type: tool_call.tool_type.clone(),
4329 function: api_function_call(&tool_call.function),
4330 }
4331}
4332
4333fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
4334 ferrum_types::ApiFunctionCall {
4335 name: function_call.name.clone(),
4336 arguments: function_call.arguments.clone(),
4337 }
4338}
4339
4340fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4341 ChatMessage {
4342 role: openai_message_role_from_api(message.role),
4343 content: message.content.clone(),
4344 reasoning: None,
4345 name: message.name.clone(),
4346 tool_calls: if message.tool_calls.is_empty() {
4347 None
4348 } else {
4349 Some(
4350 message
4351 .tool_calls
4352 .iter()
4353 .map(openai_tool_call_from_api)
4354 .collect(),
4355 )
4356 },
4357 tool_call_id: message.tool_call_id.clone(),
4358 function_call: message
4359 .function_call
4360 .as_ref()
4361 .map(openai_function_call_from_api),
4362 }
4363}
4364
4365fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
4366 match role {
4367 ferrum_types::ApiMessageRole::System => MessageRole::System,
4368 ferrum_types::ApiMessageRole::User => MessageRole::User,
4369 ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
4370 ferrum_types::ApiMessageRole::Function => MessageRole::Function,
4371 ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
4372 }
4373}
4374
4375fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
4376 ChatToolCall {
4377 index: None,
4378 id: tool_call.id.clone(),
4379 tool_type: tool_call.tool_type.clone(),
4380 function: openai_function_call_from_api(&tool_call.function),
4381 }
4382}
4383
4384fn openai_tool_call_delta_from_api(
4385 index: usize,
4386 tool_call: &ferrum_types::ApiToolCall,
4387) -> ChatToolCall {
4388 ChatToolCall {
4389 index: Some(usize_to_u32_saturating(index)),
4390 id: tool_call.id.clone(),
4391 tool_type: tool_call.tool_type.clone(),
4392 function: openai_function_call_from_api(&tool_call.function),
4393 }
4394}
4395
4396fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
4397 let mut delta = openai_chat_message_from_api(message);
4398 if !message.tool_calls.is_empty() {
4399 delta.tool_calls = Some(
4400 message
4401 .tool_calls
4402 .iter()
4403 .enumerate()
4404 .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
4405 .collect(),
4406 );
4407 }
4408 delta
4409}
4410
4411fn openai_function_call_from_api(
4412 function_call: &ferrum_types::ApiFunctionCall,
4413) -> ChatFunctionCall {
4414 ChatFunctionCall {
4415 name: function_call.name.clone(),
4416 arguments: function_call.arguments.clone(),
4417 }
4418}
4419
4420fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
4421 ferrum_types::ApiResponseFormat {
4422 format_type: format.format_type.clone(),
4423 json_schema: format
4424 .json_schema
4425 .as_ref()
4426 .map(|schema| ferrum_types::ApiJsonSchema {
4427 name: schema.name.clone(),
4428 schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
4429 strict: schema.strict,
4430 }),
4431 }
4432}
4433
4434fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
4435 if request.messages.is_empty() {
4436 return Err(ServerError::invalid_request(
4437 "messages array must not be empty",
4438 Some("messages"),
4439 ));
4440 }
4441
4442 if let Some(n) = request.n {
4443 if n != 1 {
4444 return Err(ServerError::unsupported_feature(
4445 "only n=1 is supported for chat completions",
4446 Some("n"),
4447 ));
4448 }
4449 }
4450
4451 if request
4452 .logit_bias
4453 .as_ref()
4454 .is_some_and(|bias| !bias.is_empty())
4455 {
4456 return Err(ServerError::unsupported_feature(
4457 "logit_bias is not supported",
4458 Some("logit_bias"),
4459 ));
4460 }
4461 if request.logprobs.unwrap_or(false) {
4462 return Err(ServerError::unsupported_feature(
4463 "logprobs is not supported",
4464 Some("logprobs"),
4465 ));
4466 }
4467 if request.top_logprobs.unwrap_or(0) > 0 {
4468 return Err(ServerError::unsupported_feature(
4469 "top_logprobs is not supported",
4470 Some("top_logprobs"),
4471 ));
4472 }
4473
4474 if let Some(top_k) = request.top_k {
4475 if top_k < -1 {
4476 return Err(ServerError::invalid_request(
4477 "top_k must be -1, 0, or a positive integer",
4478 Some("top_k"),
4479 ));
4480 }
4481 }
4482 if let Some(min_p) = request.min_p {
4483 if !min_p.is_finite() || !(0.0..=1.0).contains(&min_p) {
4484 return Err(ServerError::invalid_request(
4485 "min_p must be in range [0, 1]",
4486 Some("min_p"),
4487 ));
4488 }
4489 }
4490 if let Some(repetition_penalty) = request.repetition_penalty {
4491 if !repetition_penalty.is_finite() || repetition_penalty <= 0.0 {
4492 return Err(ServerError::invalid_request(
4493 "repetition_penalty must be positive",
4494 Some("repetition_penalty"),
4495 ));
4496 }
4497 }
4498 if let Some(presence_penalty) = request.presence_penalty {
4499 if !presence_penalty.is_finite() || !(-2.0..=2.0).contains(&presence_penalty) {
4500 return Err(ServerError::invalid_request(
4501 "presence_penalty must be in range [-2, 2]",
4502 Some("presence_penalty"),
4503 ));
4504 }
4505 }
4506 if let Some(frequency_penalty) = request.frequency_penalty {
4507 if !frequency_penalty.is_finite() || !(-2.0..=2.0).contains(&frequency_penalty) {
4508 return Err(ServerError::invalid_request(
4509 "frequency_penalty must be in range [-2, 2]",
4510 Some("frequency_penalty"),
4511 ));
4512 }
4513 }
4514
4515 if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
4516 return Err(ServerError::invalid_request(
4517 "stream_options is only valid when stream=true",
4518 Some("stream_options"),
4519 ));
4520 }
4521 ensure_response_format_supported(request)?;
4522
4523 if let Some(tools) = &request.tools {
4524 for tool in tools {
4525 if tool.tool_type != "function" {
4526 return Err(ServerError::unsupported_feature(
4527 "only function tools are supported",
4528 Some("tools"),
4529 ));
4530 }
4531 }
4532 }
4533
4534 if let Some(choice) = &request.tool_choice {
4535 match choice {
4536 ToolChoice::Mode(mode)
4537 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4538 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
4539 if request.tools.as_deref().unwrap_or_default().is_empty() {
4540 return Err(ServerError::invalid_request(
4541 "tool_choice=required requires at least one function tool",
4542 Some("tool_choice"),
4543 ));
4544 }
4545 }
4546 ToolChoice::Mode(_) => {
4547 return Err(ServerError::unsupported_feature(
4548 "unsupported tool_choice mode",
4549 Some("tool_choice"),
4550 ));
4551 }
4552 ToolChoice::Function {
4553 tool_type,
4554 function,
4555 } => {
4556 if tool_type != "function" {
4557 return Err(ServerError::unsupported_feature(
4558 "only function tool_choice is supported",
4559 Some("tool_choice"),
4560 ));
4561 }
4562 let declared = request
4563 .tools
4564 .as_deref()
4565 .unwrap_or_default()
4566 .iter()
4567 .any(|tool| tool.function.name == function.name);
4568 if !declared {
4569 return Err(ServerError::invalid_request(
4570 "tool_choice selects a function that is not declared in tools",
4571 Some("tool_choice"),
4572 ));
4573 }
4574 }
4575 }
4576 }
4577
4578 if let Some(choice) = &request.function_call {
4579 match choice {
4580 FunctionCallChoice::Mode(mode)
4581 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
4582 FunctionCallChoice::Mode(_) => {
4583 return Err(ServerError::unsupported_feature(
4584 "unsupported function_call mode",
4585 Some("function_call"),
4586 ));
4587 }
4588 FunctionCallChoice::Function { name } => {
4589 let declared = request
4590 .functions
4591 .as_deref()
4592 .unwrap_or_default()
4593 .iter()
4594 .any(|function| function.name == *name);
4595 if !declared {
4596 return Err(ServerError::invalid_request(
4597 "function_call selects a function that is not declared in functions",
4598 Some("function_call"),
4599 ));
4600 }
4601 }
4602 }
4603 }
4604
4605 Ok(())
4606}
4607
4608fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
4609 match request.tool_choice.as_ref() {
4610 Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
4611 Some(ToolChoice::Function {
4612 tool_type,
4613 function,
4614 }) => {
4615 tool_type == "function"
4616 && request
4617 .tools
4618 .as_deref()
4619 .unwrap_or_default()
4620 .iter()
4621 .any(|tool| tool.function.name == function.name)
4622 }
4623 _ => false,
4624 }
4625}
4626
4627fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
4628 let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
4629 let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
4630 let total_tokens = usize_to_u32_saturating(usage.total_tokens);
4631 Usage {
4632 prompt_tokens,
4633 completion_tokens,
4634 total_tokens,
4635 }
4636}
4637
4638fn usize_to_u32_saturating(value: usize) -> u32 {
4639 u32::try_from(value).unwrap_or(u32::MAX)
4640}
4641
4642fn ensure_response_format_supported(
4643 request: &ChatCompletionsRequest,
4644) -> std::result::Result<(), ServerError> {
4645 if let Some(rf) = &request.response_format {
4646 match rf.format_type.as_str() {
4647 "text" | "json_object" => {}
4648 "json_schema" => {
4649 let Some(schema_config) = rf.json_schema.as_ref() else {
4650 return Err(ServerError::invalid_request(
4651 "response_format.json_schema.schema is required",
4652 Some("response_format.json_schema"),
4653 ));
4654 };
4655 let Some(schema) = schema_config.schema.as_ref() else {
4656 return Err(ServerError::invalid_request(
4657 "response_format.json_schema.schema is required",
4658 Some("response_format.json_schema"),
4659 ));
4660 };
4661 if schema_config.strict.unwrap_or(false) {
4662 compiled_json_schema_validator(schema).map_err(|reason| {
4663 ServerError::invalid_request(
4664 format!("unsupported strict json_schema: {reason}"),
4665 Some("response_format.json_schema"),
4666 )
4667 })?;
4668 }
4669 }
4670 _ => {
4671 return Err(ServerError::invalid_request(
4672 "unsupported response_format.type",
4673 Some("response_format.type"),
4674 ));
4675 }
4676 }
4677 }
4678 Ok(())
4679}
4680
4681fn strict_json_schema_string(
4682 request: &ChatCompletionsRequest,
4683) -> std::result::Result<Option<String>, ServerError> {
4684 let Some(rf) = &request.response_format else {
4685 return Ok(None);
4686 };
4687 if rf.format_type != "json_schema" {
4688 return Ok(None);
4689 }
4690 let Some(schema) = &rf.json_schema else {
4691 return Err(ServerError::invalid_request(
4692 "response_format.json_schema.schema is required",
4693 Some("response_format.json_schema"),
4694 ));
4695 };
4696 let Some(schema_value) = schema.schema.as_ref() else {
4697 return Err(ServerError::invalid_request(
4698 "response_format.json_schema.schema is required",
4699 Some("response_format.json_schema"),
4700 ));
4701 };
4702 if !schema.strict.unwrap_or(false) {
4703 return Ok(None);
4704 }
4705 serde_json::to_string(schema_value).map(Some).map_err(|e| {
4706 ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
4707 })
4708}
4709
4710fn validate_hard_structured_response(
4711 request: &ChatCompletionsRequest,
4712 content: &str,
4713 validated_chat_response: Option<&ferrum_types::ApiChatResponse>,
4714) -> std::result::Result<(), ServerError> {
4715 if validated_chat_response.is_some_and(|response| !response.message.tool_calls.is_empty()) {
4719 return Ok(());
4720 }
4721 let content = validated_chat_response
4724 .map(|response| response.message.content.as_str())
4725 .unwrap_or(content);
4726 match EffectiveChatOutputContract::resolve(request) {
4727 EffectiveChatOutputContract::JsonObjectContent => {
4728 let value = serde_json::from_str::<serde_json::Value>(content).map_err(|error| {
4729 ServerError::InternalError(format!(
4730 "model output did not satisfy response_format.json_object: invalid JSON: {error}"
4731 ))
4732 })?;
4733 if !value.is_object() {
4734 return Err(ServerError::InternalError(
4735 "model output did not satisfy response_format.json_object: root must be an object"
4736 .to_string(),
4737 ));
4738 }
4739 Ok(())
4740 }
4741 EffectiveChatOutputContract::StrictJsonSchemaContent => {
4742 let Some(schema_json) = strict_json_schema_string(request)? else {
4743 return Ok(());
4744 };
4745 let schema: serde_json::Value = serde_json::from_str(&schema_json).map_err(|e| {
4746 ServerError::InternalError(format!(
4747 "strict json_schema could not be reconstructed after request validation: {e}"
4748 ))
4749 })?;
4750 validate_json_text_against_schema(&schema, content).map_err(|reason| {
4751 ServerError::InternalError(format!(
4752 "model output did not satisfy response_format.json_schema.strict: {reason}"
4753 ))
4754 })
4755 }
4756 EffectiveChatOutputContract::RequiredToolCall
4757 | EffectiveChatOutputContract::BestEffortJsonSchemaContent
4758 | EffectiveChatOutputContract::Text => Ok(()),
4759 }
4760}
4761
4762fn structured_response_error_param(contract: EffectiveChatOutputContract) -> Option<&'static str> {
4763 match contract {
4764 EffectiveChatOutputContract::JsonObjectContent => Some("response_format"),
4765 EffectiveChatOutputContract::StrictJsonSchemaContent => Some("response_format.json_schema"),
4766 _ => None,
4767 }
4768}
4769
4770fn validate_structured_tool_response(
4771 request: &ChatCompletionsRequest,
4772 response: &ferrum_types::ApiChatResponse,
4773) -> std::result::Result<(), ServerError> {
4774 let required = tool_choice_required(request);
4775 if response.message.tool_calls.is_empty() {
4776 if required {
4777 return Err(ServerError::invalid_request(
4778 "model output did not satisfy required tool_choice",
4779 Some("tool_choice"),
4780 ));
4781 }
4782 return Ok(());
4783 }
4784
4785 if tool_choice_none(request.tool_choice.as_ref()) {
4786 return Err(ServerError::InternalError(
4787 "model emitted a tool call while tool_choice is 'none'".to_string(),
4788 ));
4789 }
4790
4791 if required {
4792 if !response.message.content.trim().is_empty() {
4793 return Err(ServerError::InternalError(
4794 "required tool response contained assistant content".to_string(),
4795 ));
4796 }
4797 if response.finish_reason.as_deref() != Some("tool_calls") {
4798 return Err(ServerError::InternalError(
4799 "required tool response did not finish with tool_calls".to_string(),
4800 ));
4801 }
4802 }
4803
4804 let tools = request.tools.as_deref().unwrap_or_default();
4805 for call in &response.message.tool_calls {
4806 if call.tool_type != "function" {
4807 return Err(ServerError::InternalError(format!(
4808 "model emitted unsupported tool call type '{}'",
4809 call.tool_type
4810 )));
4811 }
4812 let Some(tool) = tools
4813 .iter()
4814 .find(|tool| tool.tool_type == "function" && tool.function.name == call.function.name)
4815 else {
4816 return Err(ServerError::InternalError(format!(
4817 "model emitted undeclared tool call '{}'",
4818 call.function.name
4819 )));
4820 };
4821 if let Some(ToolChoice::Function {
4822 tool_type,
4823 function,
4824 }) = request.tool_choice.as_ref()
4825 {
4826 if tool_type != "function" || function.name != call.function.name {
4827 return Err(ServerError::InternalError(format!(
4828 "model emitted tool '{}' instead of selected tool '{}'",
4829 call.function.name, function.name
4830 )));
4831 }
4832 }
4833
4834 let arguments: serde_json::Value =
4835 serde_json::from_str(&call.function.arguments).map_err(|e| {
4836 ServerError::InternalError(format!(
4837 "model emitted invalid JSON arguments for tool '{}': {e}",
4838 call.function.name
4839 ))
4840 })?;
4841 if !arguments.is_object() {
4842 return Err(ServerError::InternalError(format!(
4843 "model emitted non-object arguments for tool '{}'",
4844 call.function.name
4845 )));
4846 }
4847 if let Some(schema) = tool
4852 .function
4853 .parameters
4854 .as_ref()
4855 .filter(|_| required || tool.function.strict.unwrap_or(false))
4856 {
4857 validate_json_text_against_schema(schema, &call.function.arguments).map_err(
4858 |reason| {
4859 ServerError::InternalError(format!(
4860 "model arguments for tool '{}' did not satisfy its schema: {reason}",
4861 call.function.name
4862 ))
4863 },
4864 )?;
4865 }
4866 }
4867 Ok(())
4868}
4869
4870fn validate_json_text_against_schema(
4871 schema: &serde_json::Value,
4872 content: &str,
4873) -> std::result::Result<(), String> {
4874 let value = serde_json::from_str::<serde_json::Value>(content)
4875 .map_err(|e| format!("invalid JSON: {e}"))?;
4876 compiled_json_schema_validator(schema)?
4877 .validate(&value)
4878 .map_err(|error| error.to_string())
4879}
4880
4881fn compiled_json_schema_validator(
4882 schema: &serde_json::Value,
4883) -> std::result::Result<Arc<jsonschema::Validator>, String> {
4884 let cache_key = serde_json::to_string(schema)
4885 .map_err(|error| format!("could not serialize JSON Schema: {error}"))?;
4886 let cache = JSON_SCHEMA_VALIDATOR_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
4887 let mut validators = cache
4888 .lock()
4889 .map_err(|_| "JSON Schema validator cache lock was poisoned".to_string())?;
4890 if let Some(validator) = validators.get(&cache_key) {
4891 return Ok(Arc::clone(validator));
4892 }
4893
4894 let validator = Arc::new(
4895 jsonschema::validator_for(schema)
4896 .map_err(|error| format!("could not compile JSON Schema: {error}"))?,
4897 );
4898 if validators.len() >= MAX_CACHED_JSON_SCHEMA_VALIDATORS {
4899 validators.clear();
4900 }
4901 validators.insert(cache_key, Arc::clone(&validator));
4902 Ok(validator)
4903}
4904
4905fn stream_validation_error_message(error: ServerError) -> String {
4906 match error {
4907 ServerError::InternalError(message)
4908 | ServerError::NotImplemented(message)
4909 | ServerError::ServiceUnavailable(message)
4910 | ServerError::ContextLengthExceeded(message)
4911 | ServerError::InvalidRequest { message, .. }
4912 | ServerError::UnsupportedFeature { message, .. } => message,
4913 }
4914}
4915
4916fn server_error_from_ferrum_error(error: Error) -> ServerError {
4917 match error {
4918 Error::RequestValidation { message } => ServerError::invalid_request(message, None),
4919 error @ Error::ContextLengthExceeded { .. } => {
4920 ServerError::ContextLengthExceeded(error.to_string())
4921 }
4922 Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
4923 other => ServerError::InternalError(other.to_string()),
4924 }
4925}
4926
4927fn stream_error_payload(
4928 message: impl Into<String>,
4929 error_type: &str,
4930 param: Option<&str>,
4931) -> OpenAiError {
4932 OpenAiError {
4933 error: OpenAiErrorDetail {
4934 message: message.into(),
4935 error_type: error_type.to_string(),
4936 param: param.map(str::to_string),
4937 code: None,
4938 },
4939 }
4940}
4941
4942fn openai_error_sse_event(
4943 message: impl Into<String>,
4944 error_type: &str,
4945 param: Option<&str>,
4946) -> Event {
4947 Event::default()
4948 .json_data(&stream_error_payload(message, error_type, param))
4949 .unwrap_or_else(|_| Event::default().data("error"))
4950}
4951
4952fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
4953 let prompt = request
4954 .prompt
4955 .as_text()
4956 .expect("completion prompt validated before conversion");
4957 InferenceRequest {
4958 id: RequestId(Uuid::new_v4()),
4959 model_id: ModelId(request.model.clone()),
4960 prompt: prompt.to_string(),
4961 sampling_params: SamplingParams {
4962 max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
4963 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
4964 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
4965 top_k: None,
4966 repetition_penalty: 1.0,
4967 presence_penalty: 0.0,
4968 frequency_penalty: 0.0,
4969 stop_sequences: request.stop.clone().unwrap_or_default(),
4970 seed: None,
4971 min_p: None,
4972 tfs: None,
4973 typical_p: None,
4974 mirostat: None,
4975 response_format: ferrum_types::ResponseFormat::Text,
4976 structured_output_start: StructuredOutputStart::Immediate,
4977 response_completion_boundary: ResponseCompletionBoundary::Immediate,
4978 model_output_protocol: ferrum_types::ModelOutputProtocol::Text,
4979 },
4980 stream: request.stream.unwrap_or(false),
4981 priority: Priority::Normal,
4982 client_id: None,
4983 session_id: None,
4984 created_at: chrono::Utc::now(),
4985 api_request: Some(ferrum_types::ApiRequest::Completion(
4986 ferrum_types::ApiCompletionRequest {
4987 prompt: prompt.to_string(),
4988 response_format: None,
4989 },
4990 )),
4991 evidence_request: Default::default(),
4992 metadata: if request.max_tokens.is_none() {
4993 HashMap::from([(
4994 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
4995 serde_json::json!(true),
4996 )])
4997 } else {
4998 HashMap::new()
4999 },
5000 }
5001}
5002
5003fn resolve_request_model<'a>(
5004 registry: &'a ServedModelRegistry,
5005 request_model: &str,
5006 required_kind: ServedModelKind,
5007) -> std::result::Result<(ModelId, Option<&'a LoraAdapterModel>), ServerError> {
5008 if registry.is_empty() {
5009 return Ok((ModelId::new(request_model), None));
5010 }
5011 let entry = registry
5012 .resolve(request_model, required_kind)
5013 .ok_or_else(|| {
5014 ServerError::invalid_request(format!("unknown model: {request_model}"), Some("model"))
5015 })?;
5016 Ok((entry.engine_model_id().clone(), entry.adapter()))
5017}
5018
5019fn apply_served_model_resolution(
5020 inference_request: &mut InferenceRequest,
5021 engine_model_id: ModelId,
5022 adapter: Option<&LoraAdapterModel>,
5023) {
5024 inference_request.model_id = engine_model_id;
5025 if let Some(adapter) = adapter {
5026 inference_request.metadata.insert(
5027 "ferrum_lora_adapter".to_string(),
5028 serde_json::json!(adapter.name),
5029 );
5030 inference_request.metadata.insert(
5031 "ferrum_lora_model_id".to_string(),
5032 serde_json::json!(adapter.model_id),
5033 );
5034 inference_request.metadata.insert(
5035 "ferrum_lora_path".to_string(),
5036 serde_json::json!(adapter.path),
5037 );
5038 }
5039}
5040
5041async fn handle_completions_sync(
5042 state: AppState,
5043 openai_request: CompletionsRequest,
5044 inference_request: InferenceRequest,
5045) -> std::result::Result<Response, ServerError> {
5046 let engine = state.llm.clone().ok_or_else(|| {
5047 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
5048 })?;
5049 match engine.infer(inference_request).await {
5050 Ok(output) => {
5051 let InferenceResponse {
5052 text: output_text,
5053 finish_reason,
5054 usage,
5055 api_response,
5056 ..
5057 } = output;
5058 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
5059 let mut text = strip_after_stop(&output_text, &stop_sequences);
5060 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
5061 if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
5062 api_response.as_ref()
5063 {
5064 text = strip_after_stop(&completion_response.text, &stop_sequences);
5065 if let Some(reason) = &completion_response.finish_reason {
5066 openai_finish_reason = reason.clone();
5067 }
5068 }
5069 let response = CompletionsResponse {
5070 id: Uuid::new_v4().to_string(),
5071 object: "text_completion".to_string(),
5072 created: chrono::Utc::now().timestamp() as u64,
5073 model: openai_request.model,
5074 choices: vec![CompletionChoice {
5075 text,
5076 index: 0,
5077 finish_reason: Some(openai_finish_reason),
5078 }],
5079 usage: Some(openai_usage_from_token_usage(&usage)),
5080 };
5081 Ok(Json(response).into_response())
5082 }
5083 Err(e) => {
5084 error!("Completion generation failed: {}", e);
5085 Err(server_error_from_ferrum_error(e))
5086 }
5087 }
5088}
5089
5090async fn handle_completions_stream(
5091 state: AppState,
5092 openai_request: CompletionsRequest,
5093 inference_request: InferenceRequest,
5094) -> std::result::Result<Response, ServerError> {
5095 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
5096 let engine = state.llm.clone().ok_or_else(|| {
5097 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
5098 })?;
5099 let request_id = Uuid::new_v4().to_string();
5100
5101 let mut stream = engine.infer_stream(inference_request).await.map_err(|e| {
5104 error!("Failed to start completion stream: {}", e);
5105 server_error_from_ferrum_error(e)
5106 })?;
5107 tokio::spawn(async move {
5108 while let Some(result) = stream.next().await {
5109 match result {
5110 Ok(chunk) => {
5111 let response_chunk = CompletionsResponse {
5112 id: request_id.clone(),
5113 object: "text_completion".to_string(),
5114 created: chrono::Utc::now().timestamp() as u64,
5115 model: openai_request.model.clone(),
5116 choices: vec![CompletionChoice {
5117 text: chunk.text.clone(),
5118 index: 0,
5119 finish_reason: chunk
5120 .finish_reason
5121 .as_ref()
5122 .map(finish_reason_to_string),
5123 }],
5124 usage: None,
5125 };
5126 let event = Event::default()
5127 .json_data(&response_chunk)
5128 .unwrap_or_else(|_| Event::default().data("error"));
5129 if tx.send(Ok(event)).is_err() {
5130 break;
5131 }
5132 if chunk.finish_reason.is_some() {
5133 if let Some(usage) = chunk.usage.as_ref().map(openai_usage_from_token_usage)
5134 {
5135 let final_chunk = CompletionsResponse {
5136 id: request_id.clone(),
5137 object: "text_completion".to_string(),
5138 created: chrono::Utc::now().timestamp() as u64,
5139 model: openai_request.model.clone(),
5140 choices: vec![],
5141 usage: Some(usage),
5142 };
5143 let event = Event::default()
5144 .json_data(&final_chunk)
5145 .unwrap_or_else(|_| Event::default().data("error"));
5146 let _ = tx.send(Ok(event));
5147 }
5148 let _ = tx.send(Ok(Event::default().data("[DONE]")));
5149 break;
5150 }
5151 }
5152 Err(e) => {
5153 error!("Completion stream generation error: {}", e);
5154 let _ = tx.send(Ok(openai_error_sse_event(
5155 e.to_string(),
5156 "internal_server_error",
5157 None,
5158 )));
5159 let _ = tx.send(Ok(Event::default().data("[DONE]")));
5160 break;
5161 }
5162 }
5163 }
5164 });
5165
5166 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
5167 Ok(Sse::new(stream).into_response())
5168}
5169
5170async fn completions_handler(
5172 State(state): State<AppState>,
5173 request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
5174) -> std::result::Result<Response, ServerError> {
5175 let Json(request) = request.map_err(|e| {
5176 ServerError::invalid_request(format!("invalid completions request: {e}"), None)
5177 })?;
5178 validate_completion_request(&request)?;
5179 let (engine_model_id, lora_adapter) = resolve_request_model(
5180 &state.served_model_registry,
5181 &request.model,
5182 ServedModelKind::Llm,
5183 )?;
5184 let mut inference_request = convert_completion_request(&request);
5185 apply_served_model_resolution(&mut inference_request, engine_model_id, lora_adapter);
5186 if request.stream.unwrap_or(false) {
5187 handle_completions_stream(state, request, inference_request).await
5188 } else {
5189 handle_completions_sync(state, request, inference_request).await
5190 }
5191}
5192
5193fn validate_completion_request(
5194 request: &CompletionsRequest,
5195) -> std::result::Result<(), ServerError> {
5196 if request.prompt.as_text().is_none() {
5197 return Err(ServerError::invalid_request(
5198 "only string prompt is supported for completions",
5199 Some("prompt"),
5200 ));
5201 }
5202 if let Some(n) = request.n {
5203 if n != 1 {
5204 return Err(ServerError::unsupported_feature(
5205 "only n=1 is supported for completions",
5206 Some("n"),
5207 ));
5208 }
5209 }
5210 if request.logprobs.is_some() {
5211 return Err(ServerError::unsupported_feature(
5212 "logprobs is not supported for completions",
5213 Some("logprobs"),
5214 ));
5215 }
5216 if request
5217 .logit_bias
5218 .as_ref()
5219 .is_some_and(|bias| !bias.is_empty())
5220 {
5221 return Err(ServerError::unsupported_feature(
5222 "logit_bias is not supported",
5223 Some("logit_bias"),
5224 ));
5225 }
5226 Ok(())
5227}
5228
5229async fn embeddings_handler(
5231 State(state): State<AppState>,
5232 request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
5233) -> std::result::Result<Response, ServerError> {
5234 let Json(request) = request.map_err(|e| {
5235 ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
5236 })?;
5237
5238 let span = span!(Level::INFO, "embeddings", model = %request.model);
5239 let _enter = span.enter();
5240
5241 validate_embeddings_request(&request)?;
5242 resolve_request_model(
5243 &state.served_model_registry,
5244 &request.model,
5245 ServedModelKind::Embedding,
5246 )?;
5247
5248 let items: Vec<EmbeddingItem> = match request.input {
5250 EmbeddingInput::Single(text) => vec![EmbeddingItem {
5251 text: Some(text),
5252 image: None,
5253 }],
5254 EmbeddingInput::Batch(texts) => texts
5255 .into_iter()
5256 .map(|t| EmbeddingItem {
5257 text: Some(t),
5258 image: None,
5259 })
5260 .collect(),
5261 EmbeddingInput::SingleObject(item) => vec![item],
5262 EmbeddingInput::BatchObjects(items) => items,
5263 };
5264
5265 if items.is_empty() {
5266 return Err(ServerError::invalid_request(
5267 "input must not be empty",
5268 Some("input"),
5269 ));
5270 }
5271
5272 let mut data = Vec::with_capacity(items.len());
5273 let mut total_tokens = 0u32;
5274
5275 let engine = state.embed.as_ref().ok_or_else(|| {
5276 ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
5277 })?;
5278 for (idx, item) in items.iter().enumerate() {
5279 let embedding = if let Some(ref image) = item.image {
5280 engine
5281 .embed_image(image)
5282 .await
5283 .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
5284 } else if let Some(ref text) = item.text {
5285 total_tokens += text.len() as u32;
5286 engine
5287 .embed_text(text)
5288 .await
5289 .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
5290 } else {
5291 return Err(ServerError::invalid_request(
5292 "each input item must have either text or image",
5293 Some("input"),
5294 ));
5295 };
5296
5297 data.push(EmbeddingData {
5298 object: "embedding".to_string(),
5299 embedding,
5300 index: idx,
5301 });
5302 }
5303
5304 let response = EmbeddingsResponse {
5305 object: "list".to_string(),
5306 data,
5307 model: request.model,
5308 usage: EmbeddingUsage {
5309 prompt_tokens: total_tokens,
5310 total_tokens,
5311 },
5312 };
5313
5314 Ok(Json(response).into_response())
5315}
5316
5317fn validate_embeddings_request(
5318 request: &EmbeddingsRequest,
5319) -> std::result::Result<(), ServerError> {
5320 if let Some(format) = request.encoding_format.as_deref() {
5321 if !format.eq_ignore_ascii_case("float") {
5322 return Err(ServerError::unsupported_feature(
5323 "only encoding_format=float is supported for embeddings",
5324 Some("encoding_format"),
5325 ));
5326 }
5327 }
5328 Ok(())
5329}
5330
5331async fn transcriptions_handler(
5333 State(state): State<AppState>,
5334 multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
5335) -> std::result::Result<Response, ServerError> {
5336 let mut multipart = multipart.map_err(|e| {
5337 ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
5338 })?;
5339
5340 let span = span!(Level::INFO, "transcription");
5341 let _enter = span.enter();
5342
5343 let mut file_data: Option<Vec<u8>> = None;
5344 let mut language: Option<String> = None;
5345 let mut response_format: Option<String> = None;
5346
5347 while let Some(field) = multipart
5348 .next_field()
5349 .await
5350 .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
5351 {
5352 let name = field.name().unwrap_or("").to_string();
5353 match name.as_str() {
5354 "file" => {
5355 file_data = Some(
5356 field
5357 .bytes()
5358 .await
5359 .map_err(|e| {
5360 ServerError::invalid_request(format!("read file: {e}"), Some("file"))
5361 })?
5362 .to_vec(),
5363 );
5364 }
5365 "language" => {
5366 language = field.text().await.ok().filter(|s| !s.is_empty());
5367 }
5368 "response_format" => {
5369 response_format = field.text().await.ok().filter(|s| !s.is_empty());
5370 }
5371 _ => {} }
5373 }
5374
5375 validate_transcription_response_format(response_format.as_deref())?;
5376
5377 let data = file_data
5378 .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
5379
5380 let engine = state.transcribe.as_ref().ok_or_else(|| {
5381 ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
5382 })?;
5383 let text = engine
5384 .transcribe_bytes(&data, language.as_deref())
5385 .await
5386 .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
5387
5388 Ok(Json(TranscriptionResponse { text }).into_response())
5389}
5390
5391fn validate_transcription_response_format(
5392 response_format: Option<&str>,
5393) -> std::result::Result<(), ServerError> {
5394 if let Some(format) = response_format {
5395 if !format.eq_ignore_ascii_case("json") {
5396 return Err(ServerError::unsupported_feature(
5397 "only response_format=json is supported for transcriptions",
5398 Some("response_format"),
5399 ));
5400 }
5401 }
5402 Ok(())
5403}
5404
5405async fn speech_handler(
5407 State(state): State<AppState>,
5408 request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
5409) -> std::result::Result<Response, ServerError> {
5410 let Json(request) = request
5411 .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
5412
5413 let response_format = speech_output_format(&request)?;
5414 resolve_request_model(
5415 &state.served_model_registry,
5416 &request.model,
5417 ServedModelKind::Speech,
5418 )?;
5419
5420 let span = span!(Level::INFO, "speech");
5421 let _guard = span.enter();
5422
5423 let language = if request.language.is_empty() || request.language == "auto" {
5424 None
5425 } else {
5426 Some(request.language.as_str())
5427 };
5428
5429 let chunk_frames = 10usize;
5430 let tts = state.tts.as_ref().ok_or_else(|| {
5431 ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
5432 })?;
5433 let sample_rate = tts.tts_sample_rate();
5434
5435 if request.stream {
5436 let (tx, rx) =
5438 mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
5439
5440 let engine = tts.clone();
5441 let text = request.input.clone();
5442 let lang = request.language.clone();
5443
5444 tokio::task::spawn_blocking(move || {
5445 let lang_opt = if lang.is_empty() || lang == "auto" {
5446 None
5447 } else {
5448 Some(lang.as_str())
5449 };
5450 let rt = tokio::runtime::Handle::current();
5451
5452 match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
5453 Ok(chunks) => {
5454 for chunk in &chunks {
5455 let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
5456 let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
5457 }
5458 }
5459 Err(e) => {
5460 error!("TTS error: {e}");
5461 }
5462 }
5463 });
5464
5465 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
5466 let body = axum::body::Body::from_stream(stream);
5467 Ok(Response::builder()
5468 .status(200)
5469 .header("content-type", speech_content_type(response_format))
5470 .header("transfer-encoding", "chunked")
5471 .body(body)
5472 .unwrap())
5473 } else {
5474 let chunks = tts
5476 .synthesize_speech(&request.input, language, chunk_frames)
5477 .await
5478 .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
5479
5480 let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
5481 let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
5482
5483 Ok(Response::builder()
5484 .status(200)
5485 .header("content-type", speech_content_type(response_format))
5486 .header("content-length", audio_bytes.len().to_string())
5487 .body(axum::body::Body::from(audio_bytes))
5488 .unwrap())
5489 }
5490}
5491
5492#[derive(Clone, Copy)]
5493enum SpeechOutputFormat {
5494 Wav,
5495 Pcm,
5496}
5497
5498fn speech_output_format(
5499 request: &SpeechRequest,
5500) -> std::result::Result<SpeechOutputFormat, ServerError> {
5501 if request.response_format.eq_ignore_ascii_case("wav") {
5502 Ok(SpeechOutputFormat::Wav)
5503 } else if request.response_format.eq_ignore_ascii_case("pcm") {
5504 Ok(SpeechOutputFormat::Pcm)
5505 } else {
5506 Err(ServerError::unsupported_feature(
5507 "only response_format=wav or response_format=pcm is supported for speech",
5508 Some("response_format"),
5509 ))
5510 }
5511}
5512
5513fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
5514 match format {
5515 SpeechOutputFormat::Wav => "audio/wav",
5516 SpeechOutputFormat::Pcm => "audio/pcm",
5517 }
5518}
5519
5520fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
5521 match format {
5522 SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
5523 SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
5524 }
5525}
5526
5527fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
5529 let num_samples = samples.len();
5530 let data_size = num_samples * 2; let file_size = 44 + data_size;
5532
5533 let mut buf = Vec::with_capacity(file_size);
5534 buf.extend_from_slice(b"RIFF");
5536 buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
5537 buf.extend_from_slice(b"WAVE");
5538 buf.extend_from_slice(b"fmt ");
5540 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());
5544 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");
5549 buf.extend_from_slice(&(data_size as u32).to_le_bytes());
5550 buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
5551 buf
5552}
5553
5554fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
5555 let mut buf = Vec::with_capacity(samples.len() * 2);
5556 for &s in samples {
5557 let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
5558 buf.extend_from_slice(&i16_val.to_le_bytes());
5559 }
5560 buf
5561}
5562
5563async fn models_handler(
5564 State(state): State<AppState>,
5565) -> std::result::Result<Response, ServerError> {
5566 let now = chrono::Utc::now().timestamp() as u64;
5567 let reasoning = state.prompt_template.as_ref().and_then(|template| {
5568 let supported_efforts = template
5569 .reasoning_effort_support
5570 .declared_efforts()
5571 .map(|efforts| efforts.iter().copied().collect());
5572 let thinking =
5573 template
5574 .supports_thinking_control()
5575 .then(|| crate::openai::ModelThinkingCapability {
5576 default_enabled: state
5577 .default_enable_thinking
5578 .unwrap_or(template.reasoning_default_enabled),
5579 });
5580 (supported_efforts.is_some() || thinking.is_some()).then_some(
5581 crate::openai::ModelReasoningCapabilities {
5582 supported_efforts,
5583 thinking,
5584 },
5585 )
5586 });
5587 let data = state
5588 .served_model_registry
5589 .entries()
5590 .iter()
5591 .map(|entry| crate::openai::ModelInfo {
5592 id: entry.public_name().to_string(),
5593 object: "model".to_string(),
5594 created: now,
5595 owned_by: "ferrum".to_string(),
5596 max_model_len: match entry.kind() {
5597 ServedModelKind::Llm => state.llm.as_ref().and_then(|llm| llm.context_capacity()),
5598 _ => None,
5599 },
5600 reasoning: if entry.kind() == ServedModelKind::Llm
5601 && state
5602 .llm
5603 .as_ref()
5604 .is_some_and(|llm| &llm.config().model.model_id == entry.engine_model_id())
5605 {
5606 reasoning.clone()
5607 } else {
5608 None
5609 },
5610 modalities: entry
5611 .kind()
5612 .modalities()
5613 .iter()
5614 .map(ToString::to_string)
5615 .collect(),
5616 permission: vec![],
5617 root: entry.parent_public_name().map(ToString::to_string),
5618 parent: entry.parent_public_name().map(ToString::to_string),
5619 })
5620 .collect();
5621
5622 let models = ModelListResponse {
5623 object: "list".to_string(),
5624 data,
5625 };
5626
5627 Ok(Json(models).into_response())
5628}
5629
5630async fn health_handler(
5631 State(state): State<AppState>,
5632) -> std::result::Result<Response, ServerError> {
5633 let engine_status = state.status().await;
5634 let scheduler_metrics = state.metrics();
5635 let runtime_config = RuntimeConfigSnapshot::capture_current();
5636 let cache_policy = CachePolicy::current();
5637 let engine_cache = state
5638 .llm
5639 .as_ref()
5640 .and_then(|engine| engine.cache_metrics_snapshot());
5641 let execution_attribution = state
5642 .llm
5643 .as_ref()
5644 .and_then(|engine| engine.execution_attribution_snapshot());
5645 let engine_lora = state
5646 .llm
5647 .as_ref()
5648 .and_then(|engine| engine.lora_metrics_snapshot());
5649 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5650 let runtime_admission = match state.llm.as_ref() {
5651 Some(engine) => engine.admission_snapshot(),
5652 None => Ok(None),
5653 };
5654 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5655 Ok(snapshot) => (snapshot.as_ref(), None),
5656 Err(error) => (None, Some(error.to_string())),
5657 };
5658 let admission = admission_health_json(
5659 &engine_status,
5660 &scheduler_metrics,
5661 &auto_config,
5662 runtime_admission_snapshot,
5663 runtime_admission_error.as_deref(),
5664 );
5665
5666 let health = serde_json::json!({
5667 "status": if runtime_admission_error.is_some() { "unhealthy" } else { "healthy" },
5668 "reasoning_protocol": state.prompt_template.as_deref().map(ModelChatTemplate::reasoning_capability).unwrap_or_default(),
5669 "timestamp": chrono::Utc::now().to_rfc3339(),
5670 "version": env!("CARGO_PKG_VERSION"),
5671 "engine": {
5672 "active_requests": engine_status.active_requests,
5673 "queued_requests": engine_status.queued_requests,
5674 },
5675 "scheduler": {
5676 "total_requests": scheduler_metrics.total_requests,
5677 "successful_requests": scheduler_metrics.successful_requests,
5678 "failed_requests": scheduler_metrics.failed_requests,
5679 "throughput_rps": scheduler_metrics.throughput_rps,
5680 "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
5681 "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
5682 "model_execution_time_ms": scheduler_metrics
5683 .performance_breakdown
5684 .model_execution_time_ms,
5685 "iteration_lock_wait_time_ms": scheduler_metrics
5686 .performance_breakdown
5687 .other_overhead_time_ms,
5688 },
5689 "config": runtime_config,
5690 "auto_config": auto_config,
5691 "admission": admission,
5692 "numerical_execution": engine_cache.as_ref().and_then(|snapshot| snapshot.get("numerical_execution")),
5693 "kv_storage": engine_cache.as_ref().and_then(|snapshot| snapshot.get("kv_storage")),
5694 "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
5695 "execution_attribution": execution_attribution,
5696 "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
5697 "enabled": state.served_model_registry.adapter_count() > 0,
5698 "adapter_count": state.served_model_registry.adapter_count() as u64,
5699 "active_cache_bindings": 0u64,
5700 "projection_applications": 0u64,
5701 "position": "startup-routing",
5702 "source": "server-lora-registry",
5703 })),
5704 });
5705
5706 Ok(Json(health).into_response())
5707}
5708
5709async fn metrics_handler(
5711 State(state): State<AppState>,
5712) -> std::result::Result<Response, ServerError> {
5713 let mut body = match PROM_HANDLE.get() {
5714 Some(handle) => handle.render(),
5715 None => "# Prometheus recorder not initialized\n".to_string(),
5716 };
5717 if !body.ends_with('\n') {
5718 body.push('\n');
5719 }
5720 let engine_cache = state
5721 .llm
5722 .as_ref()
5723 .and_then(|engine| engine.cache_metrics_snapshot());
5724 body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
5725 let engine_status = state.status().await;
5726 let scheduler_metrics = state.metrics();
5727 let auto_config = auto_config_health_value(state.auto_config.as_ref());
5728 let runtime_admission = match state.llm.as_ref() {
5729 Some(engine) => engine.admission_snapshot(),
5730 None => Ok(None),
5731 };
5732 let (runtime_admission_snapshot, runtime_admission_error) = match &runtime_admission {
5733 Ok(snapshot) => (snapshot.as_ref(), None),
5734 Err(error) => (None, Some(error.to_string())),
5735 };
5736 let admission = admission_health_json(
5737 &engine_status,
5738 &scheduler_metrics,
5739 &auto_config,
5740 runtime_admission_snapshot,
5741 runtime_admission_error.as_deref(),
5742 );
5743 body.push_str(&admission_prometheus_metrics(&admission));
5744
5745 Ok((
5746 [(
5747 axum::http::header::CONTENT_TYPE,
5748 "text/plain; version=0.0.4; charset=utf-8",
5749 )],
5750 body,
5751 )
5752 .into_response())
5753}
5754
5755async fn root_handler() -> std::result::Result<Response, ServerError> {
5756 let info = serde_json::json!({
5757 "name": "Ferrum Inference Server",
5758 "version": env!("CARGO_PKG_VERSION"),
5759 "api_version": "v1",
5760 "status": "running"
5761 });
5762
5763 Ok(Json(info).into_response())
5764}
5765
5766#[derive(Debug)]
5768enum ServerError {
5769 InvalidRequest {
5770 message: String,
5771 param: Option<String>,
5772 },
5773 UnsupportedFeature {
5774 message: String,
5775 param: Option<String>,
5776 },
5777 InternalError(String),
5778 ContextLengthExceeded(String),
5779 NotImplemented(String),
5780 ServiceUnavailable(String),
5781}
5782
5783impl ServerError {
5784 fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
5785 Self::InvalidRequest {
5786 message: message.into(),
5787 param: param.map(str::to_string),
5788 }
5789 }
5790
5791 fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
5792 Self::UnsupportedFeature {
5793 message: message.into(),
5794 param: param.map(str::to_string),
5795 }
5796 }
5797}
5798
5799impl IntoResponse for ServerError {
5800 fn into_response(self) -> Response {
5801 let code = matches!(&self, ServerError::ContextLengthExceeded(_))
5802 .then(|| "context_length_exceeded".to_owned());
5803 let (status, message, error_type, param) = match self {
5804 ServerError::ContextLengthExceeded(message) => (
5805 AxumStatusCode::BAD_REQUEST,
5806 message,
5807 "invalid_request_error",
5808 None,
5809 ),
5810 ServerError::InvalidRequest { message, param } => (
5811 AxumStatusCode::BAD_REQUEST,
5812 message,
5813 "invalid_request_error",
5814 param,
5815 ),
5816 ServerError::UnsupportedFeature { message, param } => (
5817 AxumStatusCode::BAD_REQUEST,
5818 message,
5819 "invalid_request_error",
5820 param,
5821 ),
5822 ServerError::InternalError(msg) => (
5823 AxumStatusCode::INTERNAL_SERVER_ERROR,
5824 msg,
5825 "internal_server_error",
5826 None,
5827 ),
5828 ServerError::NotImplemented(msg) => (
5829 AxumStatusCode::SERVICE_UNAVAILABLE,
5830 msg,
5831 "service_unavailable_error",
5832 None,
5833 ),
5834 ServerError::ServiceUnavailable(msg) => (
5835 AxumStatusCode::SERVICE_UNAVAILABLE,
5836 msg,
5837 "service_unavailable_error",
5838 None,
5839 ),
5840 };
5841
5842 let error = OpenAiError {
5843 error: OpenAiErrorDetail {
5844 message,
5845 error_type: error_type.to_string(),
5846 param,
5847 code,
5848 },
5849 };
5850
5851 (status, Json(error)).into_response()
5852 }
5853}
5854
5855impl std::fmt::Display for MessageRole {
5856 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5857 match self {
5858 MessageRole::System => write!(f, "system"),
5859 MessageRole::User => write!(f, "user"),
5860 MessageRole::Assistant => write!(f, "assistant"),
5861 MessageRole::Function => write!(f, "function"),
5862 MessageRole::Tool => write!(f, "tool"),
5863 }
5864 }
5865}
5866
5867fn strip_after_stop(text: &str, stops: &[String]) -> String {
5871 let mut first: Option<usize> = None;
5872 for stop in stops {
5873 if stop.is_empty() {
5874 continue;
5875 }
5876 if let Some(idx) = text.find(stop.as_str()) {
5877 first = Some(first.map_or(idx, |current| current.min(idx)));
5878 }
5879 }
5880 match first {
5881 Some(idx) => text[..idx].to_string(),
5882 None => text.to_string(),
5883 }
5884}
5885
5886fn strip_markdown_json_fence(text: &str) -> String {
5889 let trimmed = text.trim();
5890 for prefix in ["```json\n", "```json", "```\n", "```"] {
5892 if let Some(rest) = trimmed.strip_prefix(prefix) {
5893 if let Some(inner) = rest.strip_suffix("```") {
5894 return inner.trim().to_string();
5895 }
5896 }
5897 }
5898 text.to_string()
5899}
5900
5901fn finish_reason_to_string(reason: &FinishReason) -> String {
5903 match reason {
5904 FinishReason::Length => "length".to_string(),
5905 FinishReason::Stop => "stop".to_string(),
5906 FinishReason::EOS => "stop".to_string(),
5907 FinishReason::Cancelled => "cancelled".to_string(),
5908 FinishReason::Error => "error".to_string(),
5909 FinishReason::ContentFilter => "content_filter".to_string(),
5910 }
5911}
5912
5913#[cfg(test)]
5914mod tests {
5915 mod auto_tools_json;
5916 mod engine_stop_contract;
5917 mod gemma_thought;
5918 mod harmony_stops;
5919 mod model_reasoning_metadata;
5920 mod native_tool_stream;
5921 mod reasoning_controls;
5922 mod tool_argument_strictness;
5923 mod tool_length;
5924 use super::*;
5925 use async_trait::async_trait;
5926 use axum::{
5927 body::{to_bytes, Body},
5928 http::{header, Request},
5929 response::Response,
5930 };
5931 use ferrum_interfaces::engine::{
5932 EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
5933 };
5934 use ferrum_types::{
5935 has_unclosed_thinking_block, parse_reasoning_response_started_in_think, EngineConfig,
5936 EngineMetrics, EngineStatus, EngineTokenTimingEvidence, FinishReason,
5937 HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
5938 ModelId, StreamChunk, TokenId, TokenUsage,
5939 };
5940 use futures::{stream, Stream};
5941 use serde_json::{json, Value};
5942 use std::{
5943 collections::HashMap,
5944 pin::Pin,
5945 sync::{atomic::AtomicUsize, Arc, Mutex},
5946 };
5947 use tower::ServiceExt;
5948
5949 #[test]
5950 fn strip_after_stop_removes_first_boundary() {
5951 assert_eq!(
5952 strip_after_stop(
5953 "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
5954 &["END0214Z".to_string()]
5955 ),
5956 "KS0214Z\nS0225\n"
5957 );
5958 }
5959
5960 #[test]
5961 fn gpt_oss_harmony_final_is_split_into_reasoning_and_visible_content() {
5962 let parsed = parse_chat_model_output(
5963 ModelOutputProtocol::HarmonyGptOss,
5964 "<|channel|>analysis<|message|>Reason.<|end|>\
5965 <|start|>assistant<|channel|>final<|message|>Answer.<|return|>",
5966 false,
5967 FinishReason::Stop,
5968 )
5969 .unwrap();
5970 assert_eq!(parsed.visible.content, "Answer.");
5971 assert_eq!(parsed.visible.reasoning.as_deref(), Some("Reason."));
5972 assert!(parsed.harmony_response.is_none());
5973 }
5974
5975 #[test]
5976 fn gpt_oss_harmony_tool_call_becomes_openai_structured_response() {
5977 let parsed = parse_chat_model_output(
5978 ModelOutputProtocol::HarmonyGptOss,
5979 "<|channel|>analysis<|message|>Need weather.<|end|>\
5980 <|start|>assistant<|channel|>commentary to=functions.weather\
5981 <|constrain|>json<|message|>{\"city\":\"Paris\"}<|call|>",
5982 false,
5983 FinishReason::Stop,
5984 )
5985 .unwrap();
5986 let response = parsed.harmony_response.unwrap();
5987 assert_eq!(response.finish_reason.as_deref(), Some("tool_calls"));
5988 assert_eq!(response.message.tool_calls.len(), 1);
5989 assert_eq!(response.message.tool_calls[0].function.name, "weather");
5990 assert_eq!(
5991 response.message.tool_calls[0].function.arguments,
5992 "{\"city\":\"Paris\"}"
5993 );
5994 assert!(response.message.tool_calls[0].id.starts_with("call_"));
5995 }
5996
5997 #[test]
5998 fn gpt_oss_harmony_accepts_missing_text_terminal_only_for_explicit_truncation() {
5999 let output = "<|channel|>analysis<|message|>Still reasoning";
6000 for finish_reason in [FinishReason::Stop, FinishReason::Length] {
6001 let parsed = parse_chat_model_output(
6002 ModelOutputProtocol::HarmonyGptOss,
6003 output,
6004 false,
6005 finish_reason,
6006 )
6007 .unwrap();
6008 assert_eq!(parsed.visible.reasoning.as_deref(), Some("Still reasoning"));
6009 assert!(parsed.visible.content.is_empty());
6010 }
6011 for finish_reason in [
6012 FinishReason::EOS,
6013 FinishReason::Cancelled,
6014 FinishReason::Error,
6015 FinishReason::ContentFilter,
6016 ] {
6017 assert!(parse_chat_model_output(
6018 ModelOutputProtocol::HarmonyGptOss,
6019 output,
6020 false,
6021 finish_reason,
6022 )
6023 .is_err());
6024 }
6025 }
6026
6027 #[tokio::test]
6028 async fn stop_drains_running_server_and_shuts_down_loaded_engine_once() {
6029 let engine = Arc::new(StubLlm::new("ok"));
6030 let server = Arc::new(AxumServer::from_llm(engine.clone()));
6031 let config = ServerConfig {
6032 host: "127.0.0.1".to_string(),
6033 port: 0,
6034 ..ServerConfig::default()
6035 };
6036 let server_task = {
6037 let server = Arc::clone(&server);
6038 tokio::spawn(async move { server.start(&config).await })
6039 };
6040 tokio::time::timeout(std::time::Duration::from_secs(1), async {
6041 while !server.is_running() {
6042 tokio::task::yield_now().await;
6043 }
6044 })
6045 .await
6046 .unwrap();
6047
6048 server
6049 .stop(std::time::Duration::from_secs(1))
6050 .await
6051 .unwrap();
6052 server
6053 .stop(std::time::Duration::from_secs(1))
6054 .await
6055 .unwrap();
6056 server_task.await.unwrap().unwrap();
6057
6058 assert_eq!(engine.shutdown_count.load(Ordering::Acquire), 1);
6059 assert!(!server.is_running());
6060 }
6061
6062 struct StubLlm {
6063 config: EngineConfig,
6064 resource_authority: ExecutionResourceAuthority,
6065 context_capacity: Option<usize>,
6066 text: String,
6067 stream_chunks: Option<Vec<String>>,
6068 stream_final_chunk_separate: bool,
6069 stream_tail_without_token: bool,
6070 stream_usage: Option<TokenUsage>,
6071 api_response: Option<ferrum_types::ApiResponse>,
6072 finish_reason: FinishReason,
6073 execution_attribution: Option<Value>,
6074 lora_metrics: Option<Value>,
6075 pending_stream_drop_notify: Option<Arc<Notify>>,
6076 stream_after_first_gate: Option<Arc<StreamGate>>,
6077 stream_terminal_error: bool,
6078 shutdown_count: AtomicUsize,
6079 }
6080
6081 impl StubLlm {
6082 fn new(text: &str) -> Self {
6083 let mut config = EngineConfig::default();
6084 config.model.model_id = ModelId::new("stub-model");
6085 Self {
6086 config,
6087 resource_authority: ExecutionResourceAuthority::LegacyEngine,
6088 text: text.to_string(),
6089 context_capacity: None,
6090 stream_chunks: None,
6091 stream_final_chunk_separate: false,
6092 stream_tail_without_token: false,
6093 stream_usage: Some(TokenUsage::new(5, 1)),
6094 api_response: None,
6095 finish_reason: FinishReason::EOS,
6096 execution_attribution: None,
6097 lora_metrics: None,
6098 pending_stream_drop_notify: None,
6099 stream_after_first_gate: None,
6100 stream_terminal_error: false,
6101 shutdown_count: AtomicUsize::new(0),
6102 }
6103 }
6104
6105 fn without_stream_usage(text: &str) -> Self {
6106 Self {
6107 stream_usage: None,
6108 ..Self::new(text)
6109 }
6110 }
6111
6112 fn with_stream_chunks(chunks: &[&str]) -> Self {
6113 Self {
6114 text: chunks.concat(),
6115 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
6116 stream_usage: Some(TokenUsage::new(5, chunks.len())),
6117 ..Self::new("")
6118 }
6119 }
6120
6121 fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
6122 Self {
6123 text: chunks.concat(),
6124 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
6125 stream_final_chunk_separate: true,
6126 stream_usage: Some(TokenUsage::new(5, chunks.len())),
6127 ..Self::new("")
6128 }
6129 }
6130
6131 fn with_tokenless_tail(chunks: &[&str]) -> Self {
6132 Self {
6133 stream_tail_without_token: true,
6134 ..Self::with_separate_final_stream_chunk(chunks)
6135 }
6136 }
6137
6138 fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
6139 Self {
6140 api_response: Some(api_response),
6141 ..Self::new(text)
6142 }
6143 }
6144
6145 fn with_api_response_and_finish_reason(
6146 text: &str,
6147 api_response: ferrum_types::ApiResponse,
6148 finish_reason: FinishReason,
6149 ) -> Self {
6150 Self {
6151 api_response: Some(api_response),
6152 finish_reason,
6153 ..Self::new(text)
6154 }
6155 }
6156
6157 fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
6158 Self {
6159 lora_metrics: Some(lora_metrics),
6160 ..Self::new(text)
6161 }
6162 }
6163
6164 fn with_execution_attribution(text: &str, execution_attribution: Value) -> Self {
6165 Self {
6166 execution_attribution: Some(execution_attribution),
6167 ..Self::new(text)
6168 }
6169 }
6170
6171 fn with_pending_stream(drop_notify: Arc<Notify>) -> Self {
6172 Self {
6173 pending_stream_drop_notify: Some(drop_notify),
6174 ..Self::new("")
6175 }
6176 }
6177 }
6178
6179 struct PendingDropStream {
6180 drop_notify: Arc<Notify>,
6181 }
6182
6183 #[derive(Default)]
6184 struct StreamGate {
6185 entered: Notify,
6186 resume: Notify,
6187 }
6188
6189 impl Stream for PendingDropStream {
6190 type Item = ferrum_types::Result<StreamChunk>;
6191
6192 fn poll_next(
6193 self: Pin<&mut Self>,
6194 _cx: &mut std::task::Context<'_>,
6195 ) -> std::task::Poll<Option<Self::Item>> {
6196 std::task::Poll::Pending
6197 }
6198 }
6199
6200 impl Drop for PendingDropStream {
6201 fn drop(&mut self) {
6202 self.drop_notify.notify_one();
6203 }
6204 }
6205
6206 struct StubEmbed {
6207 config: EngineConfig,
6208 }
6209
6210 impl StubEmbed {
6211 fn new() -> Self {
6212 let mut config = EngineConfig::default();
6213 config.model.model_id = ModelId::new("stub-embed");
6214 Self { config }
6215 }
6216 }
6217
6218 struct StubTranscribe {
6219 config: EngineConfig,
6220 }
6221
6222 impl StubTranscribe {
6223 fn new() -> Self {
6224 let mut config = EngineConfig::default();
6225 config.model.model_id = ModelId::new("stub-transcribe");
6226 Self { config }
6227 }
6228 }
6229
6230 struct StubTts {
6231 config: EngineConfig,
6232 }
6233
6234 impl StubTts {
6235 fn new() -> Self {
6236 let mut config = EngineConfig::default();
6237 config.model.model_id = ModelId::new("stub-tts");
6238 Self { config }
6239 }
6240 }
6241
6242 struct FailingLlm {
6243 config: EngineConfig,
6244 fail_after_stream_start: bool,
6245 infer_failure: ferrum_types::FerrumError,
6246 stream_start_failure: ferrum_types::FerrumError,
6247 stream_chunk_failure: ferrum_types::FerrumError,
6248 }
6249
6250 impl FailingLlm {
6251 fn new() -> Self {
6252 let mut config = EngineConfig::default();
6253 config.model.model_id = ModelId::new("failing-model");
6254 Self {
6255 config,
6256 fail_after_stream_start: false,
6257 infer_failure: ferrum_types::FerrumError::internal("stub generation failed"),
6258 stream_start_failure: ferrum_types::FerrumError::internal("stub stream failed"),
6259 stream_chunk_failure: ferrum_types::FerrumError::internal(
6260 "stub stream chunk failed",
6261 ),
6262 }
6263 }
6264
6265 fn after_stream_start() -> Self {
6266 Self {
6267 fail_after_stream_start: true,
6268 ..Self::new()
6269 }
6270 }
6271
6272 fn resource_exhausted() -> Self {
6273 let failure = ferrum_types::FerrumError::resource_exhausted(
6274 "admission capacity exhausted while reserving request resources",
6275 );
6276 Self {
6277 infer_failure: failure.clone(),
6278 stream_start_failure: failure.clone(),
6279 stream_chunk_failure: failure,
6280 ..Self::new()
6281 }
6282 }
6283
6284 fn context_length_exceeded() -> Self {
6285 let failure = ferrum_types::FerrumError::ContextLengthExceeded {
6286 capacity: 512,
6287 input_tokens: 500,
6288 output_tokens: 100,
6289 };
6290 Self {
6291 infer_failure: failure.clone(),
6292 stream_start_failure: failure,
6293 ..Self::new()
6294 }
6295 }
6296 }
6297
6298 struct CapturingLlm {
6299 config: EngineConfig,
6300 last_request: Mutex<Option<InferenceRequest>>,
6301 }
6302
6303 impl CapturingLlm {
6304 fn new() -> Self {
6305 let mut config = EngineConfig::default();
6306 config.model.model_id = ModelId::new("qwen3");
6307 Self {
6308 config,
6309 last_request: Mutex::new(None),
6310 }
6311 }
6312
6313 fn last_request(&self) -> InferenceRequest {
6314 self.last_request
6315 .lock()
6316 .expect("capture lock")
6317 .clone()
6318 .expect("request captured")
6319 }
6320
6321 fn has_captured_request(&self) -> bool {
6322 self.last_request.lock().expect("capture lock").is_some()
6323 }
6324 }
6325
6326 #[async_trait]
6327 impl InferenceEngine for StubLlm {
6328 async fn status(&self) -> EngineStatus {
6329 EngineStatus {
6330 is_ready: true,
6331 loaded_models: vec![self.config.model.model_id.clone()],
6332 active_requests: 0,
6333 queued_requests: 0,
6334 memory_usage: MemoryUsage {
6335 total_bytes: 0,
6336 used_bytes: 0,
6337 free_bytes: 0,
6338 gpu_memory_bytes: None,
6339 cpu_memory_bytes: None,
6340 cache_memory_bytes: 0,
6341 utilization_percent: 0.0,
6342 },
6343 uptime_seconds: 0,
6344 last_heartbeat: chrono::Utc::now(),
6345 version: "test".to_string(),
6346 }
6347 }
6348
6349 async fn shutdown(&self) -> ferrum_types::Result<()> {
6350 self.shutdown_count.fetch_add(1, Ordering::AcqRel);
6351 Ok(())
6352 }
6353
6354 fn config(&self) -> &EngineConfig {
6355 &self.config
6356 }
6357
6358 fn metrics(&self) -> EngineMetrics {
6359 EngineMetrics::default()
6360 }
6361
6362 async fn health_check(&self) -> EngineHealthStatus {
6363 EngineHealthStatus::healthy()
6364 }
6365
6366 fn execution_attribution_snapshot(&self) -> Option<Value> {
6367 self.execution_attribution.clone()
6368 }
6369
6370 fn lora_metrics_snapshot(&self) -> Option<Value> {
6371 self.lora_metrics.clone()
6372 }
6373 }
6374
6375 #[async_trait]
6376 impl InferenceEngine for StubEmbed {
6377 async fn status(&self) -> EngineStatus {
6378 EngineStatus {
6379 is_ready: true,
6380 loaded_models: vec![self.config.model.model_id.clone()],
6381 active_requests: 0,
6382 queued_requests: 0,
6383 memory_usage: MemoryUsage {
6384 total_bytes: 0,
6385 used_bytes: 0,
6386 free_bytes: 0,
6387 gpu_memory_bytes: None,
6388 cpu_memory_bytes: None,
6389 cache_memory_bytes: 0,
6390 utilization_percent: 0.0,
6391 },
6392 uptime_seconds: 0,
6393 last_heartbeat: chrono::Utc::now(),
6394 version: "test".to_string(),
6395 }
6396 }
6397
6398 async fn shutdown(&self) -> ferrum_types::Result<()> {
6399 Ok(())
6400 }
6401
6402 fn config(&self) -> &EngineConfig {
6403 &self.config
6404 }
6405
6406 fn metrics(&self) -> EngineMetrics {
6407 EngineMetrics::default()
6408 }
6409
6410 async fn health_check(&self) -> EngineHealthStatus {
6411 EngineHealthStatus::healthy()
6412 }
6413 }
6414
6415 #[async_trait]
6416 impl EmbedEngine for StubEmbed {
6417 async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
6418 Ok(vec![text.len() as f32, 1.0, 0.0])
6419 }
6420
6421 async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
6422 Ok(vec![image.len() as f32, 0.0, 1.0])
6423 }
6424
6425 fn embedding_dim(&self) -> usize {
6426 3
6427 }
6428 }
6429
6430 #[async_trait]
6431 impl InferenceEngine for StubTranscribe {
6432 async fn status(&self) -> EngineStatus {
6433 EngineStatus {
6434 is_ready: true,
6435 loaded_models: vec![self.config.model.model_id.clone()],
6436 active_requests: 0,
6437 queued_requests: 0,
6438 memory_usage: MemoryUsage {
6439 total_bytes: 0,
6440 used_bytes: 0,
6441 free_bytes: 0,
6442 gpu_memory_bytes: None,
6443 cpu_memory_bytes: None,
6444 cache_memory_bytes: 0,
6445 utilization_percent: 0.0,
6446 },
6447 uptime_seconds: 0,
6448 last_heartbeat: chrono::Utc::now(),
6449 version: "test".to_string(),
6450 }
6451 }
6452
6453 async fn shutdown(&self) -> ferrum_types::Result<()> {
6454 Ok(())
6455 }
6456
6457 fn config(&self) -> &EngineConfig {
6458 &self.config
6459 }
6460
6461 fn metrics(&self) -> EngineMetrics {
6462 EngineMetrics::default()
6463 }
6464
6465 async fn health_check(&self) -> EngineHealthStatus {
6466 EngineHealthStatus::healthy()
6467 }
6468 }
6469
6470 #[async_trait]
6471 impl TranscribeEngine for StubTranscribe {
6472 async fn transcribe_file(
6473 &self,
6474 path: &str,
6475 language: Option<&str>,
6476 ) -> ferrum_types::Result<String> {
6477 Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
6478 }
6479
6480 async fn transcribe_bytes(
6481 &self,
6482 data: &[u8],
6483 language: Option<&str>,
6484 ) -> ferrum_types::Result<String> {
6485 Ok(format!(
6486 "bytes:{}:{}",
6487 data.len(),
6488 language.unwrap_or("auto")
6489 ))
6490 }
6491 }
6492
6493 #[async_trait]
6494 impl InferenceEngine for StubTts {
6495 async fn status(&self) -> EngineStatus {
6496 EngineStatus {
6497 is_ready: true,
6498 loaded_models: vec![self.config.model.model_id.clone()],
6499 active_requests: 0,
6500 queued_requests: 0,
6501 memory_usage: MemoryUsage {
6502 total_bytes: 0,
6503 used_bytes: 0,
6504 free_bytes: 0,
6505 gpu_memory_bytes: None,
6506 cpu_memory_bytes: None,
6507 cache_memory_bytes: 0,
6508 utilization_percent: 0.0,
6509 },
6510 uptime_seconds: 0,
6511 last_heartbeat: chrono::Utc::now(),
6512 version: "test".to_string(),
6513 }
6514 }
6515
6516 async fn shutdown(&self) -> ferrum_types::Result<()> {
6517 Ok(())
6518 }
6519
6520 fn config(&self) -> &EngineConfig {
6521 &self.config
6522 }
6523
6524 fn metrics(&self) -> EngineMetrics {
6525 EngineMetrics::default()
6526 }
6527
6528 async fn health_check(&self) -> EngineHealthStatus {
6529 EngineHealthStatus::healthy()
6530 }
6531 }
6532
6533 #[async_trait]
6534 impl TtsEngine for StubTts {
6535 async fn synthesize_speech(
6536 &self,
6537 _text: &str,
6538 _language: Option<&str>,
6539 _chunk_frames: usize,
6540 ) -> ferrum_types::Result<Vec<Vec<f32>>> {
6541 Ok(vec![vec![0.0, 0.5, -0.5]])
6542 }
6543
6544 fn tts_sample_rate(&self) -> u32 {
6545 16_000
6546 }
6547 }
6548
6549 #[async_trait]
6550 impl InferenceEngine for FailingLlm {
6551 async fn status(&self) -> EngineStatus {
6552 EngineStatus {
6553 is_ready: true,
6554 loaded_models: vec![self.config.model.model_id.clone()],
6555 active_requests: 0,
6556 queued_requests: 0,
6557 memory_usage: MemoryUsage {
6558 total_bytes: 0,
6559 used_bytes: 0,
6560 free_bytes: 0,
6561 gpu_memory_bytes: None,
6562 cpu_memory_bytes: None,
6563 cache_memory_bytes: 0,
6564 utilization_percent: 0.0,
6565 },
6566 uptime_seconds: 0,
6567 last_heartbeat: chrono::Utc::now(),
6568 version: "test".to_string(),
6569 }
6570 }
6571
6572 async fn shutdown(&self) -> ferrum_types::Result<()> {
6573 Ok(())
6574 }
6575
6576 fn config(&self) -> &EngineConfig {
6577 &self.config
6578 }
6579
6580 fn metrics(&self) -> EngineMetrics {
6581 EngineMetrics::default()
6582 }
6583
6584 async fn health_check(&self) -> EngineHealthStatus {
6585 EngineHealthStatus::healthy()
6586 }
6587 }
6588
6589 #[async_trait]
6590 impl InferenceEngine for CapturingLlm {
6591 async fn status(&self) -> EngineStatus {
6592 EngineStatus {
6593 is_ready: true,
6594 loaded_models: vec![self.config.model.model_id.clone()],
6595 active_requests: 0,
6596 queued_requests: 0,
6597 memory_usage: MemoryUsage {
6598 total_bytes: 0,
6599 used_bytes: 0,
6600 free_bytes: 0,
6601 gpu_memory_bytes: None,
6602 cpu_memory_bytes: None,
6603 cache_memory_bytes: 0,
6604 utilization_percent: 0.0,
6605 },
6606 uptime_seconds: 0,
6607 last_heartbeat: chrono::Utc::now(),
6608 version: "test".to_string(),
6609 }
6610 }
6611
6612 async fn shutdown(&self) -> ferrum_types::Result<()> {
6613 Ok(())
6614 }
6615
6616 fn config(&self) -> &EngineConfig {
6617 &self.config
6618 }
6619
6620 fn metrics(&self) -> EngineMetrics {
6621 EngineMetrics::default()
6622 }
6623
6624 async fn health_check(&self) -> EngineHealthStatus {
6625 EngineHealthStatus::healthy()
6626 }
6627 }
6628
6629 fn stub_execution_evidence(
6630 request: &InferenceRequest,
6631 output_token_count: usize,
6632 ) -> Option<InferenceExecutionEvidence> {
6633 let requested = &request.evidence_request;
6634 if !requested.capture_prompt_token_ids && !requested.capture_engine_token_timing {
6635 return None;
6636 }
6637 Some(InferenceExecutionEvidence {
6638 prompt_token_ids: requested
6639 .capture_prompt_token_ids
6640 .then(|| vec![TokenId::new(101), TokenId::new(202), TokenId::new(303)])
6641 .unwrap_or_default(),
6642 output_token_ids: (0..output_token_count)
6643 .map(|index| TokenId::new(11 + index as u32))
6644 .collect(),
6645 engine_token_timing: requested.capture_engine_token_timing.then(|| {
6646 EngineTokenTimingEvidence {
6647 clock_source: "rust_std_instant".to_string(),
6648 wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
6649 wall_anchor_max_error_nanos: 500,
6650 decode_ready_nanos_since_request_start: Some(1_000_000),
6651 token_commit_nanos_since_request_start: (1..=output_token_count)
6652 .map(|ordinal| ordinal as u64 * 1_000_000)
6653 .collect(),
6654 decode_stage_intervals: Vec::new(),
6655 }
6656 }),
6657 })
6658 }
6659
6660 #[async_trait]
6661 impl LlmInferenceEngine for StubLlm {
6662 fn execution_resource_authority(&self) -> ExecutionResourceAuthority {
6663 self.resource_authority
6664 }
6665
6666 fn context_capacity(&self) -> Option<usize> {
6667 self.context_capacity
6668 }
6669
6670 async fn infer(
6671 &self,
6672 request: InferenceRequest,
6673 ) -> ferrum_types::Result<InferenceResponse> {
6674 let execution_evidence = stub_execution_evidence(&request, 2);
6675 Ok(InferenceResponse {
6676 request_id: request.id,
6677 text: self.text.clone(),
6678 tokens: vec![TokenId::new(11), TokenId::new(12)],
6679 finish_reason: self.finish_reason,
6680 usage: TokenUsage::new(7, 2),
6681 latency_ms: 1,
6682 created_at: chrono::Utc::now(),
6683 metadata: HashMap::new(),
6684 api_response: self.api_response.clone(),
6685 execution_evidence,
6686 })
6687 }
6688
6689 async fn infer_stream(
6690 &self,
6691 request: InferenceRequest,
6692 ) -> ferrum_types::Result<
6693 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6694 > {
6695 if let Some(drop_notify) = self.pending_stream_drop_notify.as_ref() {
6696 return Ok(Box::pin(PendingDropStream {
6697 drop_notify: Arc::clone(drop_notify),
6698 }));
6699 }
6700 if let Some(chunks) = &self.stream_chunks {
6701 let completion_token_count = self
6702 .stream_usage
6703 .as_ref()
6704 .map(|usage| usage.completion_tokens)
6705 .unwrap_or(chunks.len());
6706 let execution_evidence = stub_execution_evidence(&request, completion_token_count);
6707 let request_id = request.id;
6708 let mut stream_chunks = Vec::with_capacity(
6709 chunks.len() + usize::from(self.stream_final_chunk_separate),
6710 );
6711 let last = chunks.len().saturating_sub(1);
6712 for (index, text) in chunks.iter().enumerate() {
6713 let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
6714 stream_chunks.push(Ok(StreamChunk {
6715 request_id: request_id.clone(),
6716 text: text.clone(),
6717 token: (!(self.stream_tail_without_token && index == last))
6718 .then_some(TokenId::new(11 + index as u32)),
6719 finish_reason: is_final_text_chunk.then_some(self.finish_reason),
6720 usage: is_final_text_chunk
6721 .then(|| self.stream_usage.clone())
6722 .flatten(),
6723 created_at: chrono::Utc::now(),
6724 metadata: HashMap::new(),
6725 api_response: is_final_text_chunk
6726 .then(|| self.api_response.clone())
6727 .flatten(),
6728 execution_evidence: is_final_text_chunk
6729 .then(|| execution_evidence.clone())
6730 .flatten(),
6731 }));
6732 }
6733 if self.stream_final_chunk_separate {
6734 stream_chunks.push(Ok(StreamChunk {
6735 request_id,
6736 text: String::new(),
6737 token: None,
6738 finish_reason: Some(self.finish_reason),
6739 usage: self.stream_usage.clone(),
6740 created_at: chrono::Utc::now(),
6741 metadata: HashMap::new(),
6742 api_response: self.api_response.clone(),
6743 execution_evidence,
6744 }));
6745 }
6746 if self.stream_terminal_error {
6747 *stream_chunks.last_mut().expect("nonempty fixture stream") = Err(
6748 ferrum_types::FerrumError::internal("fixture generation failed"),
6749 );
6750 }
6751 if let Some(gate) = self.stream_after_first_gate.clone() {
6752 return Ok(Box::pin(stream::unfold(
6753 (stream_chunks.into_iter().enumerate(), gate),
6754 |(mut chunks, gate)| async move {
6755 let (index, chunk) = chunks.next()?;
6756 if index == 1 {
6757 gate.entered.notify_one();
6758 gate.resume.notified().await;
6759 }
6760 Some((chunk, (chunks, gate)))
6761 },
6762 )));
6763 }
6764 return Ok(Box::pin(stream::iter(stream_chunks)));
6765 }
6766
6767 let execution_evidence = stub_execution_evidence(&request, 1);
6768 let chunk = StreamChunk {
6769 request_id: request.id,
6770 text: self.text.clone(),
6771 token: Some(TokenId::new(11)),
6772 finish_reason: Some(self.finish_reason),
6773 usage: self.stream_usage.clone(),
6774 created_at: chrono::Utc::now(),
6775 metadata: HashMap::new(),
6776 api_response: self.api_response.clone(),
6777 execution_evidence,
6778 };
6779 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6780 }
6781 }
6782
6783 #[async_trait]
6784 impl LlmInferenceEngine for FailingLlm {
6785 async fn infer(
6786 &self,
6787 _request: InferenceRequest,
6788 ) -> ferrum_types::Result<InferenceResponse> {
6789 Err(self.infer_failure.clone())
6790 }
6791
6792 async fn infer_stream(
6793 &self,
6794 request: InferenceRequest,
6795 ) -> ferrum_types::Result<
6796 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6797 > {
6798 if self.fail_after_stream_start {
6799 let _request_id = request.id;
6800 return Ok(Box::pin(stream::iter(vec![Err(self
6801 .stream_chunk_failure
6802 .clone())])));
6803 }
6804 Err(self.stream_start_failure.clone())
6805 }
6806 }
6807
6808 #[async_trait]
6809 impl LlmInferenceEngine for CapturingLlm {
6810 async fn infer(
6811 &self,
6812 request: InferenceRequest,
6813 ) -> ferrum_types::Result<InferenceResponse> {
6814 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6815 Ok(InferenceResponse {
6816 request_id: request.id,
6817 text: "captured".to_string(),
6818 tokens: vec![TokenId::new(21)],
6819 finish_reason: FinishReason::Stop,
6820 usage: TokenUsage::new(9, 1),
6821 latency_ms: 1,
6822 created_at: chrono::Utc::now(),
6823 metadata: HashMap::new(),
6824 api_response: None,
6825 execution_evidence: None,
6826 })
6827 }
6828
6829 async fn infer_stream(
6830 &self,
6831 request: InferenceRequest,
6832 ) -> ferrum_types::Result<
6833 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
6834 > {
6835 *self.last_request.lock().expect("capture lock") = Some(request.clone());
6836 let chunk = StreamChunk {
6837 request_id: request.id,
6838 text: "captured".to_string(),
6839 token: Some(TokenId::new(21)),
6840 finish_reason: Some(FinishReason::Stop),
6841 usage: Some(TokenUsage::new(9, 1)),
6842 created_at: chrono::Utc::now(),
6843 metadata: HashMap::new(),
6844 api_response: None,
6845 execution_evidence: None,
6846 };
6847 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
6848 }
6849 }
6850
6851 fn state_with_stub(text: &str) -> AppState {
6852 AppState::default().with_llm(Arc::new(StubLlm::new(text)))
6853 }
6854
6855 fn router_with_stub(text: &str) -> Router {
6856 AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
6857 }
6858
6859 fn router_with_stub_and_template(text: &str, template: ModelChatTemplate) -> Router {
6860 AxumServer::from_llm(Arc::new(StubLlm::new(text)))
6861 .with_prompt_template(Some(template))
6862 .build_router()
6863 }
6864
6865 fn router_with_stub_and_request_dump_dir(text: &str, request_dump_dir: PathBuf) -> Router {
6866 AxumServer::from_state(
6867 AppState::default()
6868 .with_llm(Arc::new(StubLlm::new(text)))
6869 .with_request_dump_dir(Some(request_dump_dir)),
6870 )
6871 .build_router()
6872 }
6873
6874 fn router_with_stub_request_dump_and_profile(
6875 text: &str,
6876 request_dump_dir: PathBuf,
6877 profile_jsonl: PathBuf,
6878 ) -> Router {
6879 AxumServer::from_state(
6880 AppState::default()
6881 .with_llm(Arc::new(StubLlm::new(text)))
6882 .with_request_dump_dir(Some(request_dump_dir))
6883 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6884 .with_profile_jsonl(Some(profile_jsonl)),
6885 )
6886 .build_router()
6887 }
6888
6889 fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
6890 AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
6891 }
6892
6893 fn router_with_stub_finish_reason(text: &str, finish_reason: FinishReason) -> Router {
6894 AxumServer::from_llm(Arc::new(StubLlm {
6895 finish_reason,
6896 ..StubLlm::new(text)
6897 }))
6898 .build_router()
6899 }
6900
6901 fn router_with_stub_stream_chunks_and_request_dump_dir(
6902 chunks: &[&str],
6903 request_dump_dir: PathBuf,
6904 ) -> Router {
6905 AxumServer::from_state(
6906 AppState::default()
6907 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6908 .with_request_dump_dir(Some(request_dump_dir)),
6909 )
6910 .build_router()
6911 }
6912
6913 fn router_with_stub_stream_request_dump_and_profile(
6914 chunks: &[&str],
6915 request_dump_dir: PathBuf,
6916 profile_jsonl: PathBuf,
6917 ) -> Router {
6918 AxumServer::from_state(
6919 AppState::default()
6920 .with_llm(Arc::new(StubLlm::with_stream_chunks(chunks)))
6921 .with_request_dump_dir(Some(request_dump_dir))
6922 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
6923 .with_profile_jsonl(Some(profile_jsonl)),
6924 )
6925 .build_router()
6926 }
6927
6928 fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
6929 AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
6930 .build_router()
6931 }
6932
6933 fn router_with_stub_api_response(
6934 text: &str,
6935 api_response: ferrum_types::ApiResponse,
6936 ) -> Router {
6937 AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
6938 .build_router()
6939 }
6940
6941 fn router_with_stub_api_response_and_finish_reason(
6942 text: &str,
6943 api_response: ferrum_types::ApiResponse,
6944 finish_reason: FinishReason,
6945 ) -> Router {
6946 AxumServer::from_llm(Arc::new(StubLlm::with_api_response_and_finish_reason(
6947 text,
6948 api_response,
6949 finish_reason,
6950 )))
6951 .build_router()
6952 }
6953
6954 fn weather_tool_api_response() -> ferrum_types::ApiResponse {
6955 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6956 message: ferrum_types::ApiChatMessage {
6957 role: ferrum_types::ApiMessageRole::Assistant,
6958 content: String::new(),
6959 name: None,
6960 tool_calls: vec![ferrum_types::ApiToolCall {
6961 id: "call_1".to_string(),
6962 tool_type: "function".to_string(),
6963 function: ferrum_types::ApiFunctionCall {
6964 name: "weather".to_string(),
6965 arguments: "{\"city\":\"Paris\"}".to_string(),
6966 },
6967 }],
6968 tool_call_id: None,
6969 function_call: None,
6970 },
6971 finish_reason: Some("tool_calls".to_string()),
6972 })
6973 }
6974
6975 fn weather_tool_api_response_with_commentary() -> ferrum_types::ApiResponse {
6976 let mut response = weather_tool_api_response();
6977 let ferrum_types::ApiResponse::Chat(chat) = &mut response else {
6978 unreachable!("weather response is chat")
6979 };
6980 chat.message.content = "I will check.".to_string();
6981 response
6982 }
6983
6984 fn namespaced_tool_api_response() -> ferrum_types::ApiResponse {
6985 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
6986 message: ferrum_types::ApiChatMessage {
6987 role: ferrum_types::ApiMessageRole::Assistant,
6988 content: String::new(),
6989 name: None,
6990 tool_calls: vec![ferrum_types::ApiToolCall {
6991 id: "call_ns_1".to_string(),
6992 tool_type: "function".to_string(),
6993 function: ferrum_types::ApiFunctionCall {
6994 name: "collaboration__wait_agent".to_string(),
6995 arguments: "{}".to_string(),
6996 },
6997 }],
6998 tool_call_id: None,
6999 function_call: None,
7000 },
7001 finish_reason: Some("tool_calls".to_string()),
7002 })
7003 }
7004
7005 fn two_tool_api_response() -> ferrum_types::ApiResponse {
7006 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
7007 message: ferrum_types::ApiChatMessage {
7008 role: ferrum_types::ApiMessageRole::Assistant,
7009 content: String::new(),
7010 name: None,
7011 tool_calls: vec![
7012 ferrum_types::ApiToolCall {
7013 id: "call_1".to_string(),
7014 tool_type: "function".to_string(),
7015 function: ferrum_types::ApiFunctionCall {
7016 name: "weather".to_string(),
7017 arguments: "{}".to_string(),
7018 },
7019 },
7020 ferrum_types::ApiToolCall {
7021 id: "call_2".to_string(),
7022 tool_type: "function".to_string(),
7023 function: ferrum_types::ApiFunctionCall {
7024 name: "clock".to_string(),
7025 arguments: "{}".to_string(),
7026 },
7027 },
7028 ],
7029 tool_call_id: None,
7030 function_call: None,
7031 },
7032 finish_reason: Some("tool_calls".to_string()),
7033 })
7034 }
7035
7036 fn router_with_stub_without_stream_usage(text: &str) -> Router {
7037 AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
7038 }
7039
7040 fn router_without_llm() -> Router {
7041 AxumServer::from_state(AppState::default()).build_router()
7042 }
7043
7044 fn router_with_failing_llm() -> Router {
7045 AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
7046 }
7047
7048 fn router_with_failing_llm_and_request_dump_dir(request_dump_dir: PathBuf) -> Router {
7049 AxumServer::from_state(
7050 AppState::default()
7051 .with_llm(Arc::new(FailingLlm::new()))
7052 .with_request_dump_dir(Some(request_dump_dir)),
7053 )
7054 .build_router()
7055 }
7056
7057 fn router_with_failing_llm_request_dump_and_profile(
7058 request_dump_dir: PathBuf,
7059 profile_jsonl: PathBuf,
7060 ) -> Router {
7061 AxumServer::from_state(
7062 AppState::default()
7063 .with_llm(Arc::new(FailingLlm::new()))
7064 .with_request_dump_dir(Some(request_dump_dir))
7065 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
7066 .with_profile_jsonl(Some(profile_jsonl)),
7067 )
7068 .build_router()
7069 }
7070
7071 fn router_with_resource_exhausted_llm_and_request_dump_dir(
7072 request_dump_dir: PathBuf,
7073 ) -> Router {
7074 AxumServer::from_state(
7075 AppState::default()
7076 .with_llm(Arc::new(FailingLlm::resource_exhausted()))
7077 .with_request_dump_dir(Some(request_dump_dir)),
7078 )
7079 .build_router()
7080 }
7081
7082 fn router_with_stream_chunk_failing_llm() -> Router {
7083 AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
7084 }
7085
7086 fn router_with_stream_chunk_failing_llm_and_request_dump_dir(
7087 request_dump_dir: PathBuf,
7088 ) -> Router {
7089 AxumServer::from_state(
7090 AppState::default()
7091 .with_llm(Arc::new(FailingLlm::after_stream_start()))
7092 .with_request_dump_dir(Some(request_dump_dir)),
7093 )
7094 .build_router()
7095 }
7096
7097 fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
7098 let engine = Arc::new(CapturingLlm::new());
7099 let registry = ServedModelRegistry::try_new(
7100 "qwen3",
7101 ServedModelKind::Llm,
7102 vec![
7103 "qwen3".to_string(),
7104 "stub-model".to_string(),
7105 "served-alias".to_string(),
7106 ],
7107 vec![],
7108 )
7109 .unwrap();
7110 let router = AxumServer::from_llm(engine.clone())
7111 .with_served_model_registry(registry)
7112 .build_router();
7113 (router, engine)
7114 }
7115
7116 fn unique_request_dump_dir(test_name: &str) -> PathBuf {
7117 let path =
7118 std::env::temp_dir().join(format!("ferrum-server-{test_name}-{}", Uuid::new_v4()));
7119 fs::create_dir_all(&path).expect("create request dump dir");
7120 path
7121 }
7122
7123 fn unique_profile_jsonl(test_name: &str) -> PathBuf {
7124 std::env::temp_dir().join(format!(
7125 "ferrum-server-{test_name}-{}.jsonl",
7126 Uuid::new_v4()
7127 ))
7128 }
7129
7130 fn only_replay_bundle(root: &Path) -> PathBuf {
7131 let mut dirs = fs::read_dir(root)
7132 .expect("read request dump dir")
7133 .filter_map(|entry| {
7134 let path = entry.expect("dir entry").path();
7135 path.is_dir().then_some(path)
7136 })
7137 .collect::<Vec<_>>();
7138 dirs.sort();
7139 assert_eq!(
7140 dirs.len(),
7141 1,
7142 "expected exactly one replay bundle in {root:?}"
7143 );
7144 dirs.remove(0)
7145 }
7146
7147 fn read_json_file(path: impl AsRef<Path>) -> Value {
7148 let path = path.as_ref();
7149 let text = fs::read_to_string(path).unwrap_or_else(|err| {
7150 panic!("failed to read {}: {}", path.display(), err);
7151 });
7152 serde_json::from_str(&text).unwrap_or_else(|err| {
7153 panic!("failed to parse {}: {}", path.display(), err);
7154 })
7155 }
7156
7157 fn read_profile_events(path: &Path) -> Vec<Value> {
7158 let text = fs::read_to_string(path)
7159 .unwrap_or_else(|err| panic!("failed to read {}: {}", path.display(), err));
7160 text.lines()
7161 .filter(|line| !line.trim().is_empty())
7162 .map(|line| serde_json::from_str::<Value>(line).expect("profile event json"))
7163 .collect()
7164 }
7165
7166 fn assert_chat_failure_replay_bundle(
7167 root: &Path,
7168 expected_phase: &str,
7169 expected_error_kind: &str,
7170 expected_message: &str,
7171 ) {
7172 let bundle = only_replay_bundle(root);
7173 let request = read_json_file(bundle.join("request.json"));
7174 let request_id = request["request_id"]
7175 .as_str()
7176 .expect("request id")
7177 .to_string();
7178 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
7179 assert_eq!(bad_scan["request_id"], request_id);
7180 assert_eq!(bad_scan["failure_kind"], "error");
7181 assert_eq!(bad_scan["failure_phase"], expected_phase);
7182 assert_eq!(bad_scan["error_kind"], expected_error_kind);
7183
7184 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
7185 assert_eq!(diagnostics["request_id"], request_id);
7186 assert_eq!(diagnostics["failure_kind"], "error");
7187 assert_eq!(diagnostics["first_failure_event"]["phase"], expected_phase);
7188 assert_eq!(
7189 diagnostics["first_failure_event"]["error_kind"],
7190 expected_error_kind
7191 );
7192 assert_eq!(diagnostics["nearest_request_id"], request_id);
7193 assert!(diagnostics["log_excerpt"]
7194 .as_str()
7195 .expect("log excerpt")
7196 .contains(expected_message));
7197 assert!(bundle.join("replay.command.json").is_file());
7198 }
7199
7200 fn assert_chat_success_replay_bundle(
7201 root: &Path,
7202 expected_token_ids: &[u32],
7203 expected_finish_reason: &str,
7204 expected_output_text: &str,
7205 ) {
7206 let bundle = only_replay_bundle(root);
7207 let request = read_json_file(bundle.join("request.json"));
7208 let request_id = request["request_id"]
7209 .as_str()
7210 .expect("request id")
7211 .to_string();
7212 let prompt_tokens = read_json_file(bundle.join("prompt_token_ids.json"));
7213 assert_eq!(prompt_tokens["request_id"], request_id);
7214 assert_eq!(prompt_tokens["token_ids"], json!([101, 202, 303]));
7215 assert_eq!(prompt_tokens["token_count"], 3);
7216 assert!(prompt_tokens["unavailable_reason"].is_null());
7217 let output_tokens = read_json_file(bundle.join("output_token_ids.json"));
7218 assert_eq!(output_tokens["request_id"], request_id);
7219 assert_eq!(output_tokens["token_ids"], json!(expected_token_ids));
7220 assert_eq!(output_tokens["token_count"], expected_token_ids.len());
7221 assert_eq!(output_tokens["finish_reason"], expected_finish_reason);
7222 assert!(output_tokens["unavailable_reason"].is_null());
7223
7224 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
7225 assert_eq!(bad_scan["request_id"], request_id);
7226 assert_eq!(bad_scan["bad_output"], false);
7227 assert_eq!(bad_scan["failure_kind"], serde_json::Value::Null);
7228 assert_eq!(
7229 bad_scan["output_chars"],
7230 expected_output_text.chars().count()
7231 );
7232 assert_eq!(
7233 bad_scan["classified_output_sha256"],
7234 sha256_hex(expected_output_text.as_bytes())
7235 );
7236
7237 let output_text_bytes = fs::read(bundle.join("output_text.txt")).unwrap();
7238 assert_eq!(bad_scan["output_sha256"], sha256_hex(&output_text_bytes));
7239 let output_text = String::from_utf8(output_text_bytes).unwrap();
7240 assert!(output_text.contains("[redacted actual output]"));
7241 assert!(output_text.contains(&format!(
7242 "sha256={}",
7243 sha256_hex(expected_output_text.as_bytes())
7244 )));
7245 assert!(output_text.contains(&format!("chars={}", expected_output_text.chars().count())));
7246
7247 let replay_body = read_json_file(bundle.join("replay_body.json"));
7248 assert_eq!(replay_body["messages"][0]["role"], "user");
7249 assert_eq!(replay_body["messages"][0]["content"], "[redacted]");
7250 assert_eq!(replay_body["messages"][0]["content_redacted"], true);
7251
7252 let replay = read_json_file(bundle.join("replay.command.json"));
7253 assert_eq!(replay["requires_running_server"], true);
7254 let argv = replay["argv"].as_array().expect("replay argv");
7255 assert!(argv.iter().any(|item| item == "--data-binary"));
7256 assert!(argv.iter().any(|item| {
7257 item.as_str()
7258 .is_some_and(|value| value.starts_with('@') && value.ends_with("replay_body.json"))
7259 }));
7260 assert_eq!(replay["engine_replay"]["requires_http_server"], false);
7261 let engine_argv = replay["engine_replay"]["argv"]
7262 .as_array()
7263 .expect("engine replay argv");
7264 assert!(engine_argv.iter().any(|item| item == "replay-bundle"));
7265 }
7266
7267 fn router_with_capturing_llm_and_template(
7268 template: ModelChatTemplate,
7269 ) -> (Router, Arc<CapturingLlm>) {
7270 router_with_capturing_llm_and_template_default(template, None)
7271 }
7272
7273 fn router_with_capturing_llm_and_template_default(
7274 template: ModelChatTemplate,
7275 default_enable_thinking: Option<bool>,
7276 ) -> (Router, Arc<CapturingLlm>) {
7277 let engine = Arc::new(CapturingLlm::new());
7278 let registry = ServedModelRegistry::try_new(
7279 "qwen3",
7280 ServedModelKind::Llm,
7281 vec!["served-alias".to_string()],
7282 vec![],
7283 )
7284 .unwrap();
7285 let router = AxumServer::from_llm(engine.clone())
7286 .with_served_model_registry(registry)
7287 .with_prompt_template(Some(template))
7288 .with_default_enable_thinking(default_enable_thinking)
7289 .build_router();
7290 (router, engine)
7291 }
7292
7293 fn qwen36_chat_template() -> ModelChatTemplate {
7294 ModelChatTemplate::new(
7295 include_str!("../tests/fixtures/chat_template/Qwen__Qwen3.6-35B-A3B/template.jinja"),
7296 "Qwen/Qwen3.6-35B-A3B",
7297 )
7298 }
7299
7300 async fn capture_qwen36_tool_history_request(
7301 reasoning_fields: Value,
7302 stream: bool,
7303 ) -> InferenceRequest {
7304 let mut assistant = json!({
7305 "role": "assistant",
7306 "content": null,
7307 "tool_calls": [{
7308 "id": "call_1",
7309 "type": "function",
7310 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
7311 }]
7312 });
7313 assistant
7314 .as_object_mut()
7315 .expect("assistant message object")
7316 .extend(
7317 reasoning_fields
7318 .as_object()
7319 .expect("reasoning fields object")
7320 .clone(),
7321 );
7322 let (router, engine) = router_with_capturing_llm_and_template(qwen36_chat_template());
7323 let response = post_json(
7324 router,
7325 "/v1/chat/completions",
7326 json!({
7327 "model": "served-alias",
7328 "messages": [
7329 {"role": "user", "content": "Use the weather tool."},
7330 assistant,
7331 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
7332 ],
7333 "tools": [{
7334 "type": "function",
7335 "function": {
7336 "name": "weather",
7337 "description": "Get weather",
7338 "parameters": {
7339 "type": "object",
7340 "properties": {"city": {"type": "string"}},
7341 "required": ["city"]
7342 }
7343 }
7344 }],
7345 "stream": stream
7346 }),
7347 )
7348 .await;
7349 assert_eq!(response.status(), AxumStatusCode::OK);
7350 if stream {
7351 assert!(response_text(response).await.contains("[DONE]"));
7352 }
7353 engine.last_request()
7354 }
7355
7356 fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
7357 let engine = Arc::new(CapturingLlm::new());
7358 let router = AxumServer::from_llm(engine.clone())
7359 .with_lora_adapters(
7360 "qwen3",
7361 vec![LoraAdapterModel::new(
7362 "sql",
7363 "qwen3:sql",
7364 "/tmp/sql-adapter",
7365 )],
7366 )
7367 .unwrap()
7368 .build_router();
7369 (router, engine)
7370 }
7371
7372 fn router_with_stub_embed() -> Router {
7373 AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
7374 }
7375
7376 fn router_with_stub_transcribe() -> Router {
7377 AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
7378 }
7379
7380 fn router_with_stub_tts() -> Router {
7381 AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
7382 }
7383
7384 async fn post_json(app: Router, path: &str, body: Value) -> Response {
7385 app.oneshot(
7386 Request::builder()
7387 .method("POST")
7388 .uri(path)
7389 .header(header::CONTENT_TYPE, "application/json")
7390 .body(Body::from(body.to_string()))
7391 .expect("request"),
7392 )
7393 .await
7394 .expect("route response")
7395 }
7396
7397 async fn post_json_with_benchmark_correlation(
7398 app: Router,
7399 path: &str,
7400 body: Value,
7401 correlation: &BenchmarkRequestCorrelation,
7402 ) -> Response {
7403 app.oneshot(
7404 Request::builder()
7405 .method("POST")
7406 .uri(path)
7407 .header(header::CONTENT_TYPE, "application/json")
7408 .header(BENCHMARK_RUN_ID_HEADER, &correlation.benchmark_run_id)
7409 .header(BENCHMARK_CELL_ID_HEADER, &correlation.cell_id)
7410 .header(
7411 BENCHMARK_REPEAT_INDEX_HEADER,
7412 correlation.repeat_index.to_string(),
7413 )
7414 .header(BENCHMARK_PHASE_HEADER, correlation.phase.as_str())
7415 .header(
7416 BENCHMARK_REQUEST_INDEX_HEADER,
7417 correlation.request_index.to_string(),
7418 )
7419 .body(Body::from(body.to_string()))
7420 .expect("request"),
7421 )
7422 .await
7423 .expect("route response")
7424 }
7425
7426 async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
7427 app.oneshot(
7428 Request::builder()
7429 .method("POST")
7430 .uri(path)
7431 .header(header::CONTENT_TYPE, "application/json")
7432 .body(Body::from(body.to_string()))
7433 .expect("request"),
7434 )
7435 .await
7436 .expect("route response")
7437 }
7438
7439 async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
7440 app.oneshot(
7441 Request::builder()
7442 .method("POST")
7443 .uri(path)
7444 .header(
7445 header::CONTENT_TYPE,
7446 format!("multipart/form-data; boundary={boundary}"),
7447 )
7448 .body(Body::from(body.to_string()))
7449 .expect("request"),
7450 )
7451 .await
7452 .expect("route response")
7453 }
7454
7455 async fn get(app: Router, path: &str) -> Response {
7456 app.oneshot(
7457 Request::builder()
7458 .method("GET")
7459 .uri(path)
7460 .body(Body::empty())
7461 .expect("request"),
7462 )
7463 .await
7464 .expect("route response")
7465 }
7466
7467 async fn response_json(response: Response) -> Value {
7468 let bytes = to_bytes(response.into_body(), usize::MAX)
7469 .await
7470 .expect("body bytes");
7471 serde_json::from_slice(&bytes).expect("json body")
7472 }
7473
7474 async fn response_text(response: Response) -> String {
7475 let bytes = to_bytes(response.into_body(), usize::MAX)
7476 .await
7477 .expect("body bytes");
7478 String::from_utf8(bytes.to_vec()).expect("utf8 body")
7479 }
7480
7481 fn responses_sse_json_events(body: &str) -> Vec<Value> {
7482 body.lines()
7483 .filter_map(|line| line.strip_prefix("data: "))
7484 .filter(|data| *data != "[DONE]")
7485 .map(|data| serde_json::from_str(data).expect("Responses SSE JSON event"))
7486 .collect()
7487 }
7488
7489 async fn response_bytes(response: Response) -> Vec<u8> {
7490 to_bytes(response.into_body(), usize::MAX)
7491 .await
7492 .expect("body bytes")
7493 .to_vec()
7494 }
7495
7496 async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
7497 let response = error.into_response();
7498 let status = response.status();
7499 (status, response_json(response).await)
7500 }
7501
7502 fn assert_openai_stream_error(body: &str, expected_message: &str) {
7503 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7504 assert!(
7505 body.contains("\"error\":{\"message\":\""),
7506 "stream failure should emit OpenAI error envelope: {body}"
7507 );
7508 assert!(
7509 body.contains(expected_message),
7510 "stream failure should include engine error message {expected_message:?}: {body}"
7511 );
7512 assert!(
7513 body.contains("\"type\":\"internal_server_error\""),
7514 "stream failure should use internal_server_error: {body}"
7515 );
7516 assert!(
7517 !body.contains("{\"error\":\""),
7518 "stream failure must not use legacy bare error payload: {body}"
7519 );
7520 }
7521
7522 fn chat_request(extra: Value) -> ChatCompletionsRequest {
7523 let mut value = json!({
7524 "model": "stub-model",
7525 "messages": [{"role": "user", "content": "hello"}],
7526 "max_tokens": 8
7527 });
7528 let obj = value.as_object_mut().unwrap();
7529 for (k, v) in extra.as_object().unwrap() {
7530 obj.insert(k.clone(), v.clone());
7531 }
7532 serde_json::from_value(value).expect("chat request")
7533 }
7534
7535 #[tokio::test]
7536 async fn responses_route_returns_sync_text_and_usage() {
7537 let response = post_json(
7538 router_with_stub("hello from ferrum"),
7539 "/v1/responses",
7540 json!({
7541 "model": "stub-model",
7542 "input": "hello",
7543 "store": false
7544 }),
7545 )
7546 .await;
7547 assert_eq!(response.status(), AxumStatusCode::OK);
7548 let body = response_json(response).await;
7549 assert_eq!(body["object"], "response");
7550 assert_eq!(body["status"], "completed");
7551 assert_eq!(body["store"], false);
7552 assert_eq!(body["output"][0]["type"], "message");
7553 assert_eq!(body["output"][0]["phase"], "final_answer");
7554 assert_eq!(body["output"][0]["content"][0]["text"], "hello from ferrum");
7555 assert_eq!(body["usage"]["input_tokens"], 7);
7556 assert_eq!(body["usage"]["output_tokens"], 2);
7557 assert_eq!(body["usage"]["total_tokens"], 9);
7558 assert_eq!(body["presence_penalty"], 0.0);
7559 assert_eq!(body["frequency_penalty"], 0.0);
7560 }
7561
7562 #[tokio::test]
7563 async fn responses_route_streams_ordered_text_events_once() {
7564 let response = post_json(
7565 router_with_stub_stream_chunks(&["he", "llo"]),
7566 "/v1/responses",
7567 json!({
7568 "model": "stub-model",
7569 "input": [{"role": "user", "content": "say hello"}],
7570 "stream": true
7571 }),
7572 )
7573 .await;
7574 assert_eq!(response.status(), AxumStatusCode::OK);
7575 let body = response_text(response).await;
7576 for event in [
7577 "response.created",
7578 "response.output_item.added",
7579 "response.output_text.delta",
7580 "response.output_text.done",
7581 "response.output_item.done",
7582 "response.completed",
7583 ] {
7584 assert!(
7585 body.contains(&format!("event: {event}")),
7586 "missing {event}: {body}"
7587 );
7588 }
7589 assert_eq!(
7590 body.matches("event: response.completed").count(),
7591 1,
7592 "completed must be emitted exactly once: {body}"
7593 );
7594 assert!(
7595 body.contains("\"delta\":\"he\""),
7596 "missing first delta: {body}"
7597 );
7598 assert!(
7599 body.contains("\"delta\":\"llo\""),
7600 "missing second delta: {body}"
7601 );
7602 assert!(body.contains("\"input_tokens\":5"), "missing usage: {body}");
7603 let events = responses_sse_json_events(&body);
7604 let message_added = events
7605 .iter()
7606 .find(|event| {
7607 event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7608 })
7609 .expect("message item added");
7610 assert!(
7611 message_added["item"].get("phase").is_none(),
7612 "stream must not guess phase before later tool calls are known: {body}"
7613 );
7614 let message_done = events
7615 .iter()
7616 .find(|event| {
7617 event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7618 })
7619 .expect("message item done");
7620 assert_eq!(message_done["item"]["phase"], "final_answer");
7621 let terminal = events
7622 .iter()
7623 .find(|event| event["type"] == "response.completed")
7624 .expect("completed response");
7625 assert_eq!(terminal["response"]["output"][0]["phase"], "final_answer");
7626 let completed = body
7627 .find("event: response.completed")
7628 .expect("completed event");
7629 let done = body.find("data: [DONE]").expect("terminal DONE marker");
7630 assert!(
7631 completed < done,
7632 "DONE must follow response.completed: {body}"
7633 );
7634 }
7635
7636 #[tokio::test]
7637 async fn responses_route_supports_stateless_function_round_trip() {
7638 let tool = json!({
7639 "type": "function",
7640 "name": "weather",
7641 "description": "Get weather",
7642 "parameters": {
7643 "type": "object",
7644 "properties": {"city": {"type": "string"}},
7645 "required": ["city"]
7646 }
7647 });
7648 let first = post_json(
7649 router_with_stub_api_response("", weather_tool_api_response()),
7650 "/v1/responses",
7651 json!({
7652 "model": "stub-model",
7653 "input": "Use the weather tool",
7654 "tools": [tool.clone()],
7655 "tool_choice": "auto"
7656 }),
7657 )
7658 .await;
7659 assert_eq!(first.status(), AxumStatusCode::OK);
7660 let first_body = response_json(first).await;
7661 let call = first_body["output"][0].clone();
7662 assert_eq!(call["type"], "function_call");
7663 assert_eq!(call["call_id"], "call_1");
7664 assert_eq!(call["name"], "weather");
7665 assert_eq!(call["arguments"], "{\"city\":\"Paris\"}");
7666
7667 let second = post_json(
7668 router_with_stub("weather received"),
7669 "/v1/responses",
7670 json!({
7671 "model": "stub-model",
7672 "input": [
7673 {"role": "user", "content": "Use the weather tool"},
7674 call,
7675 {"type": "function_call_output", "call_id": "call_1", "output": "sunny"}
7676 ],
7677 "tools": [tool]
7678 }),
7679 )
7680 .await;
7681 assert_eq!(second.status(), AxumStatusCode::OK);
7682 let second_body = response_json(second).await;
7683 assert_eq!(
7684 second_body["output"][0]["content"][0]["text"],
7685 "weather received"
7686 );
7687 }
7688
7689 #[tokio::test]
7690 async fn responses_route_marks_text_before_calls_as_commentary() {
7691 let request = || {
7692 json!({
7693 "model": "stub-model",
7694 "input": "Use the weather tool",
7695 "stream": false,
7696 "tools": [{
7697 "type": "function",
7698 "name": "weather",
7699 "parameters": {"type": "object"}
7700 }]
7701 })
7702 };
7703 let sync = post_json(
7704 router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7705 "/v1/responses",
7706 request(),
7707 )
7708 .await;
7709 assert_eq!(sync.status(), AxumStatusCode::OK);
7710 let sync = response_json(sync).await;
7711 assert_eq!(sync["output"][0]["type"], "message");
7712 assert_eq!(sync["output"][0]["phase"], "commentary");
7713 assert_eq!(sync["output"][1]["type"], "function_call");
7714
7715 let mut stream_request = request();
7716 stream_request["stream"] = json!(true);
7717 let stream = post_json(
7718 router_with_stub_api_response("", weather_tool_api_response_with_commentary()),
7719 "/v1/responses",
7720 stream_request,
7721 )
7722 .await;
7723 assert_eq!(stream.status(), AxumStatusCode::OK);
7724 let body = response_text(stream).await;
7725 let events = responses_sse_json_events(&body);
7726 let message_added = events
7727 .iter()
7728 .find(|event| {
7729 event["type"] == "response.output_item.added" && event["item"]["type"] == "message"
7730 })
7731 .expect("message item added");
7732 assert!(message_added["item"].get("phase").is_none());
7733 let message_done = events
7734 .iter()
7735 .find(|event| {
7736 event["type"] == "response.output_item.done" && event["item"]["type"] == "message"
7737 })
7738 .expect("message item done");
7739 assert_eq!(message_done["item"]["phase"], "commentary");
7740 let terminal = events
7741 .iter()
7742 .find(|event| event["type"] == "response.completed")
7743 .expect("completed response");
7744 assert_eq!(terminal["response"]["output"][0]["phase"], "commentary");
7745 assert_eq!(terminal["response"]["output"][1]["type"], "function_call");
7746 }
7747
7748 #[tokio::test]
7749 async fn responses_route_accepts_real_caller_owned_second_turn_shape() {
7750 let response = post_json(
7751 router_with_stub("You first said hello."),
7752 "/v1/responses",
7753 json!({
7754 "model": "stub-model",
7755 "instructions": "Answer from the supplied history.",
7756 "input": [
7757 {
7758 "type": "message",
7759 "role": "user",
7760 "content": [{"type": "input_text", "text": "Hello"}]
7761 },
7762 {
7763 "type": "message",
7764 "role": "assistant",
7765 "content": [{"type": "output_text", "text": "Hi there!"}]
7766 },
7767 {
7768 "type": "reasoning",
7769 "encrypted_content": null,
7770 "summary": []
7771 },
7772 {
7773 "type": "message",
7774 "role": "user",
7775 "content": [{"type": "input_text", "text": "What did I say first?"}]
7776 }
7777 ],
7778 "store": false,
7779 "stream": false,
7780 "include": ["reasoning.encrypted_content"],
7781 "parallel_tool_calls": false,
7782 "prompt_cache_key": "thread-1",
7783 "reasoning": {"effort": "high", "summary": "auto"}
7784 }),
7785 )
7786 .await;
7787 assert_eq!(response.status(), AxumStatusCode::OK);
7788 let body = response_json(response).await;
7789 assert_eq!(
7790 body["output"][0]["content"][0]["text"],
7791 "You first said hello."
7792 );
7793 assert_eq!(body["parallel_tool_calls"], false);
7794 assert_eq!(body["prompt_cache_key"], "thread-1");
7795 assert_eq!(body["reasoning"]["effort"], "high");
7796 }
7797
7798 #[tokio::test]
7799 async fn responses_route_merges_instructions_with_leading_developer_message() {
7800 let template = ModelChatTemplate::new(
7801 "{% 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 %}",
7802 "strict-leading-system-template",
7803 );
7804 let response = post_json(
7805 router_with_stub_and_template("ok", template),
7806 "/v1/responses",
7807 json!({
7808 "model": "stub-model",
7809 "instructions": "Top-level instructions",
7810 "input": [
7811 {"type": "message", "role": "developer", "content": "Developer instructions"},
7812 {"type": "message", "role": "user", "content": "Hello"}
7813 ]
7814 }),
7815 )
7816 .await;
7817 assert_eq!(response.status(), AxumStatusCode::OK);
7818 }
7819
7820 #[tokio::test]
7821 async fn responses_route_adapts_interleaved_system_for_strict_template() {
7822 let template = ModelChatTemplate::new(
7823 "{% 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 }}",
7824 "strict-leading-system-template",
7825 );
7826 let response = post_json(
7827 router_with_stub_and_template("ok", template),
7828 "/v1/responses",
7829 json!({
7830 "model": "stub-model",
7831 "input": [
7832 {"role": "system", "content": "Initial instructions"},
7833 {"role": "user", "content": "Use the available tool"},
7834 {"role": "developer", "content": "Deferred tool instructions"}
7835 ]
7836 }),
7837 )
7838 .await;
7839 assert_eq!(response.status(), AxumStatusCode::OK);
7840 }
7841
7842 #[tokio::test]
7843 async fn responses_route_keeps_phase_aligned_through_system_injection() {
7844 let template = ModelChatTemplate::new(
7845 "{% 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]",
7846 "phase-alignment-template",
7847 );
7848 let response = post_json(
7849 router_with_stub_and_template(r#"{"ok":true}"#, template),
7850 "/v1/responses",
7851 json!({
7852 "model": "stub-model",
7853 "instructions": "Top-level instructions",
7854 "input": [
7855 {
7856 "type": "message",
7857 "role": "assistant",
7858 "phase": "commentary",
7859 "content": "I will inspect."
7860 },
7861 {"type": "message", "role": "user", "content": "Continue"}
7862 ],
7863 "text": {"format": {"type": "json_object"}}
7864 }),
7865 )
7866 .await;
7867 assert_eq!(response.status(), AxumStatusCode::OK);
7868 }
7869
7870 #[tokio::test]
7871 async fn responses_route_can_disable_interleaved_system_coalescing() {
7872 let template = ModelChatTemplate::new(
7873 "{% 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 %}",
7874 "strict-leading-system-template",
7875 );
7876 let mut engine = CapturingLlm::new();
7877 engine.config.model.model_id = ModelId::new("stub-model");
7878 let engine = Arc::new(engine);
7879 let router = AxumServer::from_state(
7880 AppState::default()
7881 .with_llm(engine.clone())
7882 .with_prompt_template(Some(template))
7883 .with_interleaved_system_coalescing(false),
7884 )
7885 .build_router();
7886 let response = post_json(
7887 router,
7888 "/v1/responses",
7889 json!({
7890 "model": "stub-model",
7891 "input": [
7892 {"role": "system", "content": "Initial instructions"},
7893 {"role": "user", "content": "Use the available tool"},
7894 {"role": "developer", "content": "Deferred tool instructions"}
7895 ]
7896 }),
7897 )
7898 .await;
7899 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7900 let body = response_json(response).await;
7901 assert_eq!(body["error"]["type"], "invalid_request_error");
7902 assert!(!engine.has_captured_request());
7903 assert!(
7904 body.to_string()
7905 .contains("System message must be at the beginning."),
7906 "{body}"
7907 );
7908 }
7909
7910 #[tokio::test]
7911 async fn chat_route_applies_and_can_disable_interleaved_system_coalescing() {
7912 let template = || {
7913 ModelChatTemplate::new(
7914 "{% 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 %}",
7915 "strict-leading-system-template",
7916 )
7917 };
7918 let request = || {
7919 json!({
7920 "model": "stub-model",
7921 "messages": [
7922 {"role": "system", "content": "Initial instructions"},
7923 {"role": "user", "content": "Use the available tool"},
7924 {"role": "system", "content": "Deferred tool instructions"}
7925 ]
7926 })
7927 };
7928
7929 let enabled = post_json(
7930 router_with_stub_and_template("ok", template()),
7931 "/v1/chat/completions",
7932 request(),
7933 )
7934 .await;
7935 assert_eq!(enabled.status(), AxumStatusCode::OK);
7936
7937 let consecutive = post_json(
7938 router_with_stub_and_template("ok", template()),
7939 "/v1/chat/completions",
7940 json!({
7941 "model": "stub-model",
7942 "messages": [
7943 {"role": "system", "content": "Initial instructions"},
7944 {"role": "system", "content": "Deferred tool instructions"},
7945 {"role": "user", "content": "Use the available tool"}
7946 ]
7947 }),
7948 )
7949 .await;
7950 assert_eq!(consecutive.status(), AxumStatusCode::OK);
7951
7952 let mut engine = CapturingLlm::new();
7953 engine.config.model.model_id = ModelId::new("stub-model");
7954 let engine = Arc::new(engine);
7955 let disabled_router = AxumServer::from_state(
7956 AppState::default()
7957 .with_llm(engine.clone())
7958 .with_prompt_template(Some(template()))
7959 .with_interleaved_system_coalescing(false),
7960 )
7961 .build_router();
7962 let disabled = post_json(disabled_router, "/v1/chat/completions", request()).await;
7963 assert_eq!(disabled.status(), AxumStatusCode::BAD_REQUEST);
7964 let body = response_json(disabled).await;
7965 assert_eq!(body["error"]["type"], "invalid_request_error");
7966 assert!(!engine.has_captured_request());
7967 assert!(
7968 body.to_string()
7969 .contains("System message must be at the beginning."),
7970 "{body}"
7971 );
7972 }
7973
7974 #[tokio::test]
7975 async fn responses_route_keeps_structured_output_to_one_leading_system_message() {
7976 let template = ModelChatTemplate::new(
7977 "{% 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 %}",
7978 "strict-leading-system-template",
7979 );
7980 let response = post_json(
7981 router_with_stub_and_template(r#"{"ok":true}"#, template),
7982 "/v1/responses",
7983 json!({
7984 "model": "stub-model",
7985 "instructions": "Top-level instructions",
7986 "input": [
7987 {"type": "message", "role": "developer", "content": "Developer instructions"},
7988 {"type": "message", "role": "user", "content": "Return JSON"}
7989 ],
7990 "text": {"format": {"type": "json_object"}}
7991 }),
7992 )
7993 .await;
7994 assert_eq!(response.status(), AxumStatusCode::OK);
7995 let body = response_json(response).await;
7996 assert_eq!(body["output"][0]["content"][0]["text"], r#"{"ok":true}"#);
7997 }
7998
7999 #[tokio::test]
8000 async fn responses_route_output_can_be_replayed_with_readable_reasoning() {
8001 let first = post_json(
8002 router_with_stub("<think>Checked the supplied facts.</think>\nFirst answer"),
8003 "/v1/responses",
8004 json!({
8005 "model": "stub-model",
8006 "input": "First question",
8007 "include": ["reasoning.encrypted_content"]
8008 }),
8009 )
8010 .await;
8011 assert_eq!(first.status(), AxumStatusCode::OK);
8012 let first_body = response_json(first).await;
8013 assert_eq!(first_body["output"][0]["type"], "reasoning");
8014 assert_eq!(
8015 first_body["output"][0]["content"][0]["text"],
8016 "Checked the supplied facts."
8017 );
8018 assert_eq!(first_body["output"][0]["encrypted_content"], Value::Null);
8019 assert_eq!(first_body["output"][1]["type"], "message");
8020
8021 let mut input = vec![json!({
8022 "type": "message",
8023 "role": "user",
8024 "content": [{"type": "input_text", "text": "First question"}]
8025 })];
8026 input.extend(first_body["output"].as_array().unwrap().iter().cloned());
8027 input.push(json!({
8028 "type": "message",
8029 "role": "user",
8030 "content": [{"type": "input_text", "text": "Continue"}]
8031 }));
8032 let second = post_json(
8033 router_with_stub("Second answer"),
8034 "/v1/responses",
8035 json!({"model": "stub-model", "input": input}),
8036 )
8037 .await;
8038 assert_eq!(second.status(), AxumStatusCode::OK);
8039 let second_body = response_json(second).await;
8040 assert_eq!(
8041 second_body["output"][0]["content"][0]["text"],
8042 "Second answer"
8043 );
8044 }
8045
8046 #[tokio::test]
8047 async fn responses_route_streams_reasoning_before_text_with_stable_indices() {
8048 let response = post_json(
8049 router_with_stub_stream_chunks(&["<think>inspect", " history</think>\nfinal"]),
8050 "/v1/responses",
8051 json!({
8052 "model": "stub-model",
8053 "input": "answer",
8054 "stream": true,
8055 "include": ["reasoning.encrypted_content"]
8056 }),
8057 )
8058 .await;
8059 assert_eq!(response.status(), AxumStatusCode::OK);
8060 let body = response_text(response).await;
8061 let events = responses_sse_json_events(&body);
8062 for (sequence, event) in events.iter().enumerate() {
8063 assert_eq!(
8064 event["sequence_number"], sequence,
8065 "Responses sequence numbers must be contiguous: {body}"
8066 );
8067 }
8068 for event in [
8069 "response.reasoning_text.delta",
8070 "response.reasoning_text.done",
8071 "response.output_text.delta",
8072 "response.completed",
8073 ] {
8074 assert!(
8075 body.contains(&format!("event: {event}")),
8076 "missing {event}: {body}"
8077 );
8078 }
8079 let reasoning_done = body
8080 .find("event: response.reasoning_text.done")
8081 .expect("reasoning done");
8082 let text_added = body[reasoning_done..]
8083 .find("event: response.output_item.added")
8084 .map(|offset| reasoning_done + offset)
8085 .expect("text item added");
8086 assert!(
8087 reasoning_done < text_added,
8088 "reasoning must finish before text: {body}"
8089 );
8090 assert!(
8091 body.contains("\"output_index\":0,\"content_index\":0,\"delta\":\"inspect"),
8092 "reasoning must use output index 0: {body}"
8093 );
8094 assert!(
8095 body.contains("\"output_index\":1,\"content_index\":0,\"delta\":\"final"),
8096 "text must use output index 1: {body}"
8097 );
8098 let reasoning_added = events
8099 .iter()
8100 .find(|event| {
8101 event["type"] == "response.output_item.added"
8102 && event["item"]["type"] == "reasoning"
8103 })
8104 .expect("reasoning item added");
8105 assert_eq!(reasoning_added["item"]["status"], "in_progress");
8106 let reasoning_part_added = events
8107 .iter()
8108 .find(|event| {
8109 event["type"] == "response.content_part.added"
8110 && event["part"]["type"] == "reasoning_text"
8111 })
8112 .expect("reasoning content part added");
8113 assert_eq!(reasoning_part_added["output_index"], 0);
8114 let reasoning_item_done = events
8115 .iter()
8116 .find(|event| {
8117 event["type"] == "response.output_item.done" && event["item"]["type"] == "reasoning"
8118 })
8119 .expect("reasoning item done");
8120 assert_eq!(reasoning_item_done["item"]["status"], "completed");
8121 let terminal = events
8122 .iter()
8123 .find(|event| event["type"] == "response.completed")
8124 .expect("terminal response");
8125 assert_eq!(
8126 terminal["response"]["output"][0],
8127 reasoning_item_done["item"]
8128 );
8129 assert!(
8130 body.contains("data: [DONE]"),
8131 "missing terminal marker: {body}"
8132 );
8133 }
8134
8135 #[tokio::test]
8136 async fn responses_route_streams_function_call_events() {
8137 let response = post_json(
8138 router_with_stub_api_response("", weather_tool_api_response()),
8139 "/v1/responses",
8140 json!({
8141 "model": "stub-model",
8142 "input": "Use the weather tool",
8143 "stream": true,
8144 "tools": [{
8145 "type": "function",
8146 "name": "weather",
8147 "parameters": {"type": "object"}
8148 }]
8149 }),
8150 )
8151 .await;
8152 assert_eq!(response.status(), AxumStatusCode::OK);
8153 let body = response_text(response).await;
8154 assert!(
8155 body.contains("event: response.function_call_arguments.delta"),
8156 "missing function delta: {body}"
8157 );
8158 assert!(
8159 body.contains("event: response.function_call_arguments.done"),
8160 "missing function done: {body}"
8161 );
8162 assert!(
8163 body.contains("\"call_id\":\"call_1\""),
8164 "missing call id: {body}"
8165 );
8166 assert_eq!(body.matches("event: response.completed").count(), 1);
8167 }
8168
8169 #[tokio::test]
8170 async fn responses_route_round_trips_namespace_identity_without_leaking_chat_alias() {
8171 let namespace_tool = json!({
8172 "type": "namespace",
8173 "name": "collaboration",
8174 "description": "Agent coordination tools",
8175 "tools": [{
8176 "type": "function",
8177 "name": "wait_agent",
8178 "parameters": {"type": "object"}
8179 }]
8180 });
8181 let sync = post_json(
8182 router_with_stub_api_response("", namespaced_tool_api_response()),
8183 "/v1/responses",
8184 json!({
8185 "model": "stub-model",
8186 "input": "Wait for the agent",
8187 "tools": [namespace_tool.clone()]
8188 }),
8189 )
8190 .await;
8191 assert_eq!(sync.status(), AxumStatusCode::OK);
8192 let sync_body = response_json(sync).await;
8193 assert_eq!(sync_body["output"][0]["type"], "function_call");
8194 assert_eq!(sync_body["output"][0]["namespace"], "collaboration");
8195 assert_eq!(sync_body["output"][0]["name"], "wait_agent");
8196
8197 let stream = post_json(
8198 router_with_stub_api_response("", namespaced_tool_api_response()),
8199 "/v1/responses",
8200 json!({
8201 "model": "stub-model",
8202 "input": "Wait for the agent",
8203 "tools": [namespace_tool],
8204 "stream": true
8205 }),
8206 )
8207 .await;
8208 assert_eq!(stream.status(), AxumStatusCode::OK);
8209 let stream_body = response_text(stream).await;
8210 let events = responses_sse_json_events(&stream_body);
8211 let function_events = events
8212 .iter()
8213 .filter(|event| {
8214 event["item"]["type"] == "function_call"
8215 || event["type"] == "response.function_call_arguments.done"
8216 })
8217 .collect::<Vec<_>>();
8218 assert!(!function_events.is_empty());
8219 for event in function_events {
8220 let value = event.get("item").unwrap_or(event);
8221 assert_eq!(value["namespace"], "collaboration");
8222 assert_eq!(value["name"], "wait_agent");
8223 }
8224 assert!(stream_body.contains("data: [DONE]"));
8225 assert!(!stream_body.contains("collaboration__wait_agent"));
8226 }
8227
8228 #[tokio::test]
8229 async fn responses_route_enforces_parallel_tool_call_constraint() {
8230 let tools = json!([
8231 {"type": "function", "name": "weather", "parameters": {"type": "object"}},
8232 {"type": "function", "name": "clock", "parameters": {"type": "object"}}
8233 ]);
8234 let sync = post_json(
8235 router_with_stub_api_response("", two_tool_api_response()),
8236 "/v1/responses",
8237 json!({
8238 "model": "stub-model",
8239 "input": "Use both tools",
8240 "tools": tools.clone(),
8241 "parallel_tool_calls": false
8242 }),
8243 )
8244 .await;
8245 assert_eq!(sync.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
8246
8247 let stream = post_json(
8248 router_with_stub_api_response("", two_tool_api_response()),
8249 "/v1/responses",
8250 json!({
8251 "model": "stub-model",
8252 "input": "Use both tools",
8253 "tools": tools,
8254 "parallel_tool_calls": false,
8255 "stream": true
8256 }),
8257 )
8258 .await;
8259 assert_eq!(stream.status(), AxumStatusCode::OK);
8260 let body = response_text(stream).await;
8261 assert!(
8262 body.contains("event: response.failed"),
8263 "missing failure: {body}"
8264 );
8265 assert!(
8266 !body.contains("event: response.completed"),
8267 "must not complete: {body}"
8268 );
8269 assert!(
8270 body.contains("data: [DONE]"),
8271 "missing terminal marker: {body}"
8272 );
8273 }
8274
8275 #[tokio::test]
8276 async fn responses_route_streams_incomplete_terminal_event() {
8277 let response = post_json(
8278 router_with_stub_finish_reason("partial", FinishReason::Length),
8279 "/v1/responses",
8280 json!({"model": "stub-model", "input": "answer", "stream": true}),
8281 )
8282 .await;
8283 assert_eq!(response.status(), AxumStatusCode::OK);
8284 let body = response_text(response).await;
8285 assert!(
8286 body.contains("event: response.incomplete"),
8287 "missing incomplete terminal event: {body}"
8288 );
8289 assert!(
8290 !body.contains("event: response.completed"),
8291 "incomplete response must not emit completed: {body}"
8292 );
8293 assert!(
8294 body.contains("data: [DONE]"),
8295 "missing terminal marker: {body}"
8296 );
8297 let events = responses_sse_json_events(&body);
8298 let output_done = events
8299 .iter()
8300 .find(|event| event["type"] == "response.output_item.done")
8301 .expect("incomplete output item done event");
8302 assert_eq!(output_done["item"]["status"], "incomplete");
8303 let terminal = events
8304 .iter()
8305 .find(|event| event["type"] == "response.incomplete")
8306 .expect("incomplete terminal event");
8307 assert_eq!(terminal["response"]["output"][0]["status"], "incomplete");
8308 }
8309
8310 #[tokio::test]
8311 async fn responses_route_marks_sync_length_output_incomplete() {
8312 let response = post_json(
8313 router_with_stub_finish_reason("partial", FinishReason::Length),
8314 "/v1/responses",
8315 json!({"model": "stub-model", "input": "answer"}),
8316 )
8317 .await;
8318 assert_eq!(response.status(), AxumStatusCode::OK);
8319 let body = response_json(response).await;
8320 assert_eq!(body["status"], "incomplete");
8321 assert_eq!(body["output"][0]["status"], "incomplete");
8322 }
8323
8324 #[tokio::test]
8325 async fn responses_route_rejects_state_and_non_function_tools() {
8326 for (extra, param) in [
8327 (json!({"store": true}), "store"),
8328 (
8329 json!({"previous_response_id": "resp_previous"}),
8330 "previous_response_id",
8331 ),
8332 (
8333 json!({"tools": [{"type": "mcp", "server_label": "docs"}]}),
8334 "tools[0].type",
8335 ),
8336 ] {
8337 let mut body = json!({"model": "stub-model", "input": "hello"});
8338 body.as_object_mut()
8339 .unwrap()
8340 .extend(extra.as_object().unwrap().clone());
8341 let response = post_json(router_with_stub("unused"), "/v1/responses", body).await;
8342 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8343 let error = response_json(response).await;
8344 assert_eq!(error["error"]["param"], param, "error: {error}");
8345 }
8346 }
8347
8348 #[tokio::test]
8349 async fn responses_mvp_keeps_chat_completions_route_working() {
8350 let response = post_json(
8351 router_with_stub("chat still works"),
8352 "/v1/chat/completions",
8353 json!({
8354 "model": "stub-model",
8355 "messages": [{"role": "user", "content": "hello"}]
8356 }),
8357 )
8358 .await;
8359 assert_eq!(response.status(), AxumStatusCode::OK);
8360 let body = response_json(response).await;
8361 assert_eq!(body["choices"][0]["message"]["content"], "chat still works");
8362 }
8363
8364 #[test]
8365 fn sanitized_chat_request_body_redacts_user_text_and_secret_metadata() {
8366 let request = chat_request(json!({
8367 "messages": [{"role": "user", "content": "private prompt"}],
8368 "metadata": {"api_key": "should-not-survive"},
8369 "stream": true
8370 }));
8371 let body = sanitized_chat_request_body(&request);
8372 assert_eq!(body["model"], "stub-model");
8373 assert_eq!(body["stream"], true);
8374 assert_eq!(body["messages"][0]["role"], "user");
8375 assert_eq!(body["messages"][0]["content"], "[redacted]");
8376 assert_eq!(body["messages"][0]["content_redacted"], true);
8377 assert_eq!(body["messages"][0]["content_chars"], 14);
8378 assert_eq!(body["metadata"]["api_key"], "[redacted]");
8379 }
8380
8381 #[test]
8382 fn admission_health_prefers_runtime_authority_over_preflight_estimate() {
8383 let engine_status = EngineStatus {
8384 is_ready: true,
8385 loaded_models: Vec::new(),
8386 active_requests: 2,
8387 queued_requests: 1,
8388 memory_usage: MemoryUsage {
8389 total_bytes: 0,
8390 used_bytes: 0,
8391 free_bytes: 0,
8392 gpu_memory_bytes: None,
8393 cpu_memory_bytes: None,
8394 cache_memory_bytes: 0,
8395 utilization_percent: 0.0,
8396 },
8397 uptime_seconds: 0,
8398 last_heartbeat: chrono::Utc::now(),
8399 version: "test".to_owned(),
8400 };
8401 let runtime = ferrum_types::ExecutorAdmissionSnapshot::new(
8402 ferrum_types::ExecutionResourceAuthority::PlanRuntime,
8403 ferrum_types::ExecutorAdmissionLimits::new(32, 4096).unwrap(),
8404 2,
8405 7,
8406 23,
8407 None,
8408 Some(3),
8409 )
8410 .unwrap();
8411 let admission = admission_health_json(
8412 &engine_status,
8413 &EngineMetrics::default(),
8414 &json!({
8415 "admission": {
8416 "effective_max_concurrent": 16,
8417 "scheduler_policy": "continuous"
8418 }
8419 }),
8420 Some(&runtime),
8421 None,
8422 );
8423
8424 assert_eq!(admission["source"], "runtime_executor");
8425 assert_eq!(admission["runtime_snapshot_available"], true);
8426 assert_eq!(admission["resource_authority"], "plan_runtime");
8427 assert_eq!(admission["effective_max_concurrent"], 32);
8428 assert_eq!(admission["maximum_active_sequences"], 32);
8429 assert_eq!(admission["maximum_scheduled_tokens"], 4096);
8430 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8431 assert_eq!(admission["active_sequences"], 30);
8432 assert_eq!(admission["active_prefill"], 7);
8433 assert_eq!(admission["active_decode"], 23);
8434 assert!(admission["current_batch_size"].is_null());
8435 assert_eq!(admission["queue_depth"], 2);
8436 assert_eq!(admission["capacity_blocked_requests"], 3);
8437 }
8438
8439 #[test]
8440 fn admission_health_surfaces_runtime_contract_failure_without_preflight_fallback() {
8441 let engine_status = EngineStatus {
8442 is_ready: true,
8443 loaded_models: Vec::new(),
8444 active_requests: 32,
8445 queued_requests: 1,
8446 memory_usage: MemoryUsage {
8447 total_bytes: 0,
8448 used_bytes: 0,
8449 free_bytes: 0,
8450 gpu_memory_bytes: None,
8451 cpu_memory_bytes: None,
8452 cache_memory_bytes: 0,
8453 utilization_percent: 0.0,
8454 },
8455 uptime_seconds: 0,
8456 last_heartbeat: chrono::Utc::now(),
8457 version: "test".to_owned(),
8458 };
8459 let admission = admission_health_json(
8460 &engine_status,
8461 &EngineMetrics::default(),
8462 &json!({
8463 "admission": {
8464 "effective_max_concurrent": 16,
8465 "scheduler_policy": "continuous"
8466 }
8467 }),
8468 None,
8469 Some("active phase count exceeded the runtime ceiling"),
8470 );
8471
8472 assert_eq!(admission["source"], "runtime_error");
8473 assert_eq!(admission["runtime_snapshot_available"], false);
8474 assert_eq!(admission["preflight_effective_max_concurrent"], 16);
8475 assert!(admission["effective_max_concurrent"].is_null());
8476 assert!(admission["queue_depth"].is_null());
8477 assert_eq!(
8478 admission["runtime_contract_error"],
8479 "active phase count exceeded the runtime ceiling"
8480 );
8481 }
8482
8483 #[tokio::test]
8484 async fn route_health_includes_runtime_config_snapshot() {
8485 let response = get(router_with_stub("ok"), "/health").await;
8486 assert_eq!(response.status(), AxumStatusCode::OK);
8487 let body = response_json(response).await;
8488 assert_eq!(body["status"], "healthy");
8489 assert!(body["config"]["entries"].is_array(), "body: {body}");
8490 assert_eq!(body["auto_config"]["schema_version"], 1);
8491 assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
8492 assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
8493 assert_eq!(body["admission"]["schema_version"], 2);
8494 assert!(body["admission"]["effective_max_concurrent"].is_number());
8495 assert!(body["admission"]["queue_depth"].is_number());
8496 assert!(body["admission"]["active_sequences"].is_number());
8497 assert!(body["admission"]["active_prefill"].is_null());
8498 assert!(body["admission"]["active_decode"].is_null());
8499 assert!(body["admission"]["current_batch_size"].is_null());
8500 assert!(body["admission"]["rejected_requests_total"].is_number());
8501 assert!(body["admission"]["failed_requests_total"].is_number());
8502 assert!(body["admission"]["completed_requests_total"].is_number());
8503 assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
8504 assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
8505 assert!(body["scheduler"]["scheduling_time_ms"].is_number());
8506 assert!(body["scheduler"]["model_execution_time_ms"].is_number());
8507 assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
8508 assert!(
8509 body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
8510 "body: {body}"
8511 );
8512 }
8513
8514 #[tokio::test]
8515 async fn route_metrics_includes_admission_counters() {
8516 let response = get(router_with_stub("ok"), "/metrics").await;
8517 assert_eq!(response.status(), AxumStatusCode::OK);
8518 let body = response_text(response).await;
8519 for metric in [
8520 "ferrum_admission_runtime_snapshot_available",
8521 "ferrum_admission_effective_max_concurrent",
8522 "ferrum_admission_queue_depth",
8523 "ferrum_admission_active_sequences",
8524 "ferrum_admission_rejected_requests_total",
8525 "ferrum_admission_failed_requests_total",
8526 "ferrum_admission_completed_requests_total",
8527 ] {
8528 assert!(body.contains(metric), "missing {metric}:\n{body}");
8529 }
8530 for unavailable_metric in [
8531 "ferrum_admission_maximum_active_sequences ",
8532 "ferrum_admission_maximum_scheduled_tokens ",
8533 "ferrum_admission_capacity_blocked_requests ",
8534 "ferrum_admission_active_prefill ",
8535 "ferrum_admission_active_decode ",
8536 "ferrum_admission_current_batch_size ",
8537 ] {
8538 assert!(
8539 !body.contains(unavailable_metric),
8540 "unknown metric was encoded as a real value: {unavailable_metric}\n{body}"
8541 );
8542 }
8543 }
8544
8545 #[tokio::test]
8546 async fn route_health_includes_engine_lora_metrics_snapshot() {
8547 let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
8548 "ok",
8549 json!({
8550 "enabled": true,
8551 "adapter_count": 1,
8552 "active_cache_bindings": 0,
8553 "projection_applications": 7,
8554 "position": "real-inference",
8555 "source": "test-lora",
8556 }),
8557 )))
8558 .with_lora_adapters(
8559 "stub-model",
8560 vec![LoraAdapterModel::new(
8561 "sql",
8562 "stub-model:sql",
8563 "/tmp/sql-adapter",
8564 )],
8565 )
8566 .unwrap()
8567 .build_router();
8568 let response = get(router, "/health").await;
8569 assert_eq!(response.status(), AxumStatusCode::OK);
8570 let body = response_json(response).await;
8571 assert_eq!(body["lora"]["enabled"], true);
8572 assert_eq!(body["lora"]["adapter_count"], 1);
8573 assert_eq!(body["lora"]["projection_applications"], 7);
8574 assert_eq!(body["lora"]["position"], "real-inference");
8575 assert_eq!(body["lora"]["source"], "test-lora");
8576 }
8577
8578 #[tokio::test]
8579 async fn route_health_includes_engine_execution_attribution_snapshot() {
8580 let router = AxumServer::from_llm(Arc::new(StubLlm::with_execution_attribution(
8581 "ok",
8582 json!({
8583 "schema": "ferrum.vnext.provider-attribution.v1",
8584 "attribution_basis": "resolved_plan_and_completed_static_initialization",
8585 "provider_attribution": {
8586 "expected_quant_tensor_count": 400,
8587 "attributed_quant_tensor_count": 400,
8588 "expected_operation_count": 3,
8589 "attributed_operation_count": 3,
8590 "expected_item_count": 403,
8591 "attributed_item_count": 403,
8592 "percent": 100.0,
8593 "denominator_sha256": "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8594 },
8595 "fallback_counts": {"silent": 0, "dense": 0, "legacy": 0}
8596 }),
8597 )))
8598 .build_router();
8599 let response = get(router, "/health").await;
8600 assert_eq!(response.status(), AxumStatusCode::OK);
8601 let body = response_json(response).await;
8602 assert_eq!(
8603 body["execution_attribution"]["provider_attribution"]["expected_item_count"],
8604 403
8605 );
8606 assert_eq!(
8607 body["execution_attribution"]["provider_attribution"]["denominator_sha256"],
8608 "5e366997e15e1a94d90b1ae07281269e8a46f75904306564d56354c8ebea2e4e"
8609 );
8610 assert_eq!(
8611 body["execution_attribution"]["fallback_counts"],
8612 json!({"silent": 0, "dense": 0, "legacy": 0})
8613 );
8614 }
8615
8616 #[tokio::test]
8617 async fn route_models_lists_loaded_stub_model() {
8618 let response = get(router_with_stub("ok"), "/v1/models").await;
8619 assert_eq!(response.status(), AxumStatusCode::OK);
8620 let body = response_json(response).await;
8621 assert_eq!(body["object"], "list");
8622 let data = body["data"].as_array().expect("models data array");
8623 assert_eq!(data.len(), 1, "body: {body}");
8624 assert_eq!(data[0]["id"], "stub-model");
8625 assert_eq!(data[0]["object"], "model");
8626 assert_eq!(data[0]["owned_by"], "ferrum");
8627 assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
8628 assert_eq!(data[0]["modalities"], json!(["text"]));
8629 assert!(data[0]["permission"].as_array().unwrap().is_empty());
8630 assert!(data[0]["root"].is_null());
8631 assert!(data[0]["parent"].is_null());
8632 assert!(data[0].get("max_model_len").is_none());
8633 }
8634
8635 #[tokio::test]
8636 async fn route_chat_public_alias_maps_to_internal_model_and_is_echoed() {
8637 let engine = Arc::new(CapturingLlm::new());
8638 let registry = ServedModelRegistry::try_new(
8639 "qwen3",
8640 ServedModelKind::Llm,
8641 vec!["served-alias".to_string(), "secondary-alias".to_string()],
8642 vec![],
8643 )
8644 .unwrap();
8645 let router = AxumServer::from_llm(engine.clone())
8646 .with_served_model_registry(registry)
8647 .build_router();
8648 let response = post_json(
8649 router,
8650 "/v1/chat/completions",
8651 json!({
8652 "model": "secondary-alias",
8653 "messages": [{"role": "user", "content": "Say hi"}],
8654 "max_tokens": 8
8655 }),
8656 )
8657 .await;
8658
8659 assert_eq!(response.status(), AxumStatusCode::OK);
8660 let body = response_json(response).await;
8661 assert_eq!(body["model"], "secondary-alias");
8662 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
8663 }
8664
8665 #[tokio::test]
8666 async fn route_models_lists_public_aliases_without_internal_model_id() {
8667 let registry = ServedModelRegistry::try_new(
8668 "qwen3",
8669 ServedModelKind::Llm,
8670 vec!["served-alias".to_string(), "secondary-alias".to_string()],
8671 vec![],
8672 )
8673 .unwrap();
8674 let router = AxumServer::from_llm(Arc::new(CapturingLlm::new()))
8675 .with_served_model_registry(registry)
8676 .build_router();
8677 let body = response_json(get(router, "/v1/models").await).await;
8678 let ids = body["data"]
8679 .as_array()
8680 .unwrap()
8681 .iter()
8682 .map(|entry| entry["id"].as_str().unwrap())
8683 .collect::<Vec<_>>();
8684
8685 assert_eq!(ids, vec!["served-alias", "secondary-alias"]);
8686 assert!(!ids.contains(&"qwen3"));
8687 assert!(body["data"]
8688 .as_array()
8689 .unwrap()
8690 .iter()
8691 .all(|entry| entry["modalities"] == json!(["text"])));
8692 }
8693
8694 #[tokio::test]
8695 async fn route_models_lists_embedding_registry_capabilities() {
8696 let body = response_json(get(router_with_stub_embed(), "/v1/models").await).await;
8697 let data = body["data"].as_array().unwrap();
8698
8699 assert_eq!(data.len(), 1);
8700 assert_eq!(data[0]["id"], "stub-embed");
8701 assert_eq!(data[0]["modalities"], json!(["text", "image"]));
8702 assert!(data[0].get("max_model_len").is_none());
8703 }
8704
8705 #[tokio::test]
8706 async fn route_models_reports_engine_capacity_for_public_aliases_and_adapters() {
8707 let capacity = 3072;
8708 let engine = StubLlm {
8709 context_capacity: Some(capacity),
8710 ..StubLlm::new("ok")
8711 };
8712 let registry = ServedModelRegistry::try_new(
8713 "stub-model",
8714 ServedModelKind::Llm,
8715 vec!["public-model".to_owned(), "second-alias".to_owned()],
8716 vec![LoraAdapterModel::new(
8717 "sql",
8718 "public-model:sql",
8719 "/tmp/adapter",
8720 )],
8721 )
8722 .unwrap();
8723 let router = AxumServer::from_llm(Arc::new(engine))
8724 .with_served_model_registry(registry)
8725 .build_router();
8726 let body = response_json(get(router, "/v1/models").await).await;
8727 let entries = body["data"].as_array().unwrap();
8728 assert_eq!(entries.len(), 3);
8729 for entry in entries {
8730 assert_eq!(entry["max_model_len"], capacity);
8731 }
8732 }
8733
8734 #[tokio::test]
8735 async fn route_models_lists_startup_lora_adapters() {
8736 let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
8737 .with_lora_adapters(
8738 "stub-model",
8739 vec![LoraAdapterModel::new(
8740 "sql",
8741 "stub-model:sql",
8742 "/tmp/sql-adapter",
8743 )],
8744 )
8745 .unwrap()
8746 .build_router();
8747 let response = get(router, "/v1/models").await;
8748 assert_eq!(response.status(), AxumStatusCode::OK);
8749 let body = response_json(response).await;
8750 let data = body["data"].as_array().expect("models data array");
8751 let ids: Vec<_> = data
8752 .iter()
8753 .map(|item| item["id"].as_str().unwrap_or_default())
8754 .collect();
8755 assert!(ids.contains(&"stub-model"), "body: {body}");
8756 assert!(ids.contains(&"stub-model:sql"), "body: {body}");
8757 let adapter = data
8758 .iter()
8759 .find(|item| item["id"] == "stub-model:sql")
8760 .expect("adapter model");
8761 assert_eq!(adapter["root"], "stub-model");
8762 assert_eq!(adapter["parent"], "stub-model");
8763 assert_eq!(adapter["modalities"], json!(["text"]));
8764 }
8765
8766 #[tokio::test]
8767 async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
8768 let (router, engine) = router_with_capturing_lora_llm();
8769 let response = post_json(
8770 router,
8771 "/v1/chat/completions",
8772 json!({
8773 "model": "qwen3:sql",
8774 "messages": [{"role": "user", "content": "Say hi"}],
8775 "max_tokens": 8,
8776 "temperature": 0.0
8777 }),
8778 )
8779 .await;
8780 assert_eq!(response.status(), AxumStatusCode::OK);
8781 let body = response_json(response).await;
8782 assert_eq!(body["model"], "qwen3:sql");
8783 let captured = engine.last_request();
8784 assert_eq!(captured.model_id, ModelId::new("qwen3"));
8785 assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
8786 assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
8787 }
8788
8789 #[tokio::test]
8790 async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
8791 let (router, engine) = router_with_capturing_lora_llm();
8792 let response = post_json(
8793 router,
8794 "/v1/chat/completions",
8795 json!({
8796 "model": "qwen3",
8797 "messages": [{"role": "user", "content": "Say hi"}],
8798 "max_tokens": 8,
8799 "temperature": 0.0
8800 }),
8801 )
8802 .await;
8803 assert_eq!(response.status(), AxumStatusCode::OK);
8804 let captured = engine.last_request();
8805 assert_eq!(captured.model_id, ModelId::new("qwen3"));
8806 assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
8807 }
8808
8809 #[tokio::test]
8810 async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
8811 let (router, _) = router_with_capturing_lora_llm();
8812 let response = post_json(
8813 router,
8814 "/v1/chat/completions",
8815 json!({
8816 "model": "qwen3:missing",
8817 "messages": [{"role": "user", "content": "Say hi"}],
8818 "max_tokens": 8
8819 }),
8820 )
8821 .await;
8822 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8823 let body = response_json(response).await;
8824 assert_eq!(body["error"]["type"], "invalid_request_error");
8825 assert_eq!(body["error"]["param"], "model");
8826 assert!(
8827 body["error"]["message"]
8828 .as_str()
8829 .unwrap_or_default()
8830 .contains("unknown model"),
8831 "body: {body}"
8832 );
8833 }
8834
8835 #[tokio::test]
8836 async fn route_chat_unknown_served_model_returns_openai_model_error() {
8837 let engine = Arc::new(CapturingLlm::new());
8838 let router = AxumServer::from_llm(engine.clone()).build_router();
8839 let response = post_json(
8840 router,
8841 "/v1/chat/completions",
8842 json!({
8843 "model": "not-a-loaded-model",
8844 "messages": [{"role": "user", "content": "Say hi"}],
8845 "max_tokens": 8
8846 }),
8847 )
8848 .await;
8849 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
8850 let body = response_json(response).await;
8851 assert_eq!(body["error"]["type"], "invalid_request_error");
8852 assert_eq!(body["error"]["param"], "model");
8853 assert!(
8854 body["error"]["message"]
8855 .as_str()
8856 .unwrap_or_default()
8857 .contains("unknown model"),
8858 "body: {body}"
8859 );
8860 assert!(!engine.has_captured_request());
8861 }
8862
8863 #[tokio::test]
8864 async fn route_models_without_engine_returns_empty_list() {
8865 let response = get(router_without_llm(), "/v1/models").await;
8866 assert_eq!(response.status(), AxumStatusCode::OK);
8867 let body = response_json(response).await;
8868 assert_eq!(body["object"], "list");
8869 assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
8870 }
8871
8872 #[tokio::test]
8873 async fn route_basic_chat_contract_uses_stub_engine() {
8874 let response = post_json(
8875 router_with_stub("hello"),
8876 "/v1/chat/completions",
8877 json!({
8878 "model": "stub-model",
8879 "messages": [{"role": "user", "content": "Say hi"}],
8880 "max_tokens": 8,
8881 "temperature": 0.0
8882 }),
8883 )
8884 .await;
8885 assert_eq!(response.status(), AxumStatusCode::OK);
8886 let body = response_json(response).await;
8887 assert_eq!(body["object"], "chat.completion");
8888 assert_eq!(body["choices"][0]["message"]["role"], "assistant");
8889 assert_eq!(body["choices"][0]["message"]["content"], "hello");
8890 assert_eq!(body["usage"]["prompt_tokens"], 7);
8891 assert_eq!(body["usage"]["completion_tokens"], 2);
8892 }
8893
8894 #[tokio::test]
8895 async fn route_chat_serializes_structured_tool_call_response() {
8896 let response = post_json(
8897 router_with_stub_api_response(
8898 "",
8899 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
8900 message: ferrum_types::ApiChatMessage {
8901 role: ferrum_types::ApiMessageRole::Assistant,
8902 content: String::new(),
8903 name: None,
8904 tool_calls: vec![ferrum_types::ApiToolCall {
8905 id: "call_1".to_string(),
8906 tool_type: "function".to_string(),
8907 function: ferrum_types::ApiFunctionCall {
8908 name: "weather".to_string(),
8909 arguments: "{\"city\":\"Paris\"}".to_string(),
8910 },
8911 }],
8912 tool_call_id: None,
8913 function_call: None,
8914 },
8915 finish_reason: Some("tool_calls".to_string()),
8916 }),
8917 ),
8918 "/v1/chat/completions",
8919 json!({
8920 "model": "stub-model",
8921 "messages": [{"role": "user", "content": "Use the weather tool."}],
8922 "tools": [{
8923 "type": "function",
8924 "function": {
8925 "name": "weather",
8926 "parameters": {
8927 "type": "object",
8928 "properties": {"city": {"type": "string"}},
8929 "required": ["city"]
8930 }
8931 }
8932 }],
8933 "tool_choice": "auto"
8934 }),
8935 )
8936 .await;
8937 assert_eq!(response.status(), AxumStatusCode::OK);
8938 let body = response_json(response).await;
8939 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
8940 assert_eq!(
8941 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
8942 "weather"
8943 );
8944 assert_eq!(
8945 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
8946 "{\"city\":\"Paris\"}"
8947 );
8948 }
8949
8950 #[tokio::test]
8951 async fn route_chat_preserves_length_over_structured_tool_response() {
8952 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
8953 let response = post_json(
8954 router_with_stub_api_response_and_finish_reason(
8955 generated,
8956 weather_tool_api_response(),
8957 FinishReason::Length,
8958 ),
8959 "/v1/chat/completions",
8960 json!({
8961 "model": "stub-model",
8962 "messages": [{"role": "user", "content": "Use the weather tool."}],
8963 "tools": [{
8964 "type": "function",
8965 "function": {"name": "weather", "parameters": {"type": "object"}}
8966 }],
8967 "tool_choice": "auto"
8968 }),
8969 )
8970 .await;
8971 assert_eq!(response.status(), AxumStatusCode::OK);
8972 let body = response_json(response).await;
8973 assert_eq!(body["choices"][0]["finish_reason"], "length");
8974 assert_eq!(body["choices"][0]["message"]["content"], generated);
8975 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
8976 }
8977
8978 #[tokio::test]
8979 async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
8980 let response = post_json(
8981 router_with_stub(
8982 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
8983 ),
8984 "/v1/chat/completions",
8985 json!({
8986 "model": "stub-model",
8987 "messages": [{"role": "user", "content": "Use the weather tool."}],
8988 "tools": [{
8989 "type": "function",
8990 "function": {
8991 "name": "weather",
8992 "parameters": {
8993 "type": "object",
8994 "properties": {"city": {"type": "string"}},
8995 "required": ["city"]
8996 }
8997 }
8998 }],
8999 "tool_choice": "auto"
9000 }),
9001 )
9002 .await;
9003 assert_eq!(response.status(), AxumStatusCode::OK);
9004 let body = response_json(response).await;
9005 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9006 assert_eq!(body["choices"][0]["message"]["content"], "");
9007 assert_eq!(
9008 body["choices"][0]["message"]["tool_calls"][0]["id"],
9009 "call_1"
9010 );
9011 assert_eq!(
9012 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9013 "weather"
9014 );
9015 assert_eq!(
9016 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9017 "{\"city\":\"Paris\"}"
9018 );
9019 }
9020
9021 #[tokio::test]
9022 async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
9023 let response = post_json(
9024 router_with_stub(
9025 r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
9026 ),
9027 "/v1/chat/completions",
9028 json!({
9029 "model": "stub-model",
9030 "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
9031 "tools": [{
9032 "type": "function",
9033 "function": {
9034 "name": "get_weather",
9035 "parameters": {
9036 "type": "object",
9037 "properties": {
9038 "city": {"type": "string"},
9039 "unit": {"type": "string", "enum": ["c", "f"]}
9040 },
9041 "required": ["city"]
9042 }
9043 }
9044 }],
9045 "tool_choice": "auto"
9046 }),
9047 )
9048 .await;
9049 assert_eq!(response.status(), AxumStatusCode::OK);
9050 let body = response_json(response).await;
9051 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9052 assert_eq!(body["choices"][0]["message"]["content"], "");
9053 assert_eq!(
9054 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9055 "get_weather"
9056 );
9057 assert_eq!(
9058 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9059 "{\"city\":\"北京\",\"unit\":\"c\"}"
9060 );
9061 }
9062
9063 #[tokio::test]
9064 async fn route_chat_uses_template_tool_protocol_for_function_parameter_xml() {
9065 let template = ModelChatTemplate::new(
9066 "{% 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 %}",
9067 "function-parameter-xml-template",
9068 );
9069 let response = post_json(
9070 router_with_stub_and_template(
9071 "<tool_call>\n<function=get_weather>\n<parameter=city>\n北京\n</parameter>\n<parameter=unit>\ncelsius\n</parameter>\n</function>\n</tool_call>",
9072 template,
9073 ),
9074 "/v1/chat/completions",
9075 json!({
9076 "model": "stub-model",
9077 "messages": [{"role": "user", "content": "请调用 get_weather 查询北京天气。"}],
9078 "tools": [{
9079 "type": "function",
9080 "function": {
9081 "name": "get_weather",
9082 "parameters": {
9083 "type": "object",
9084 "properties": {
9085 "city": {"type": "string"},
9086 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9087 },
9088 "required": ["city"]
9089 }
9090 }
9091 }]
9092 }),
9093 )
9094 .await;
9095
9096 assert_eq!(response.status(), AxumStatusCode::OK);
9097 let body = response_json(response).await;
9098 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9099 assert_eq!(
9100 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9101 "get_weather"
9102 );
9103 assert_eq!(
9104 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9105 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9106 );
9107 }
9108
9109 fn xml_object_argument_tool_request(stream: bool) -> Value {
9110 json!({
9111 "model": "stub-model",
9112 "messages": [{"role": "user", "content": "Weather in Berlin with a forecast."}],
9113 "stream": stream,
9114 "tools": [{
9115 "type": "function",
9116 "function": {
9117 "name": "get_weather",
9118 "parameters": {
9119 "type": "object",
9120 "$defs": {
9121 "WeatherOptions": {
9122 "type": "object",
9123 "properties": {
9124 "unit": {"type": "string"},
9125 "include_forecast": {"type": "boolean"}
9126 },
9127 "required": ["unit", "include_forecast"],
9128 "additionalProperties": false
9129 }
9130 },
9131 "properties": {
9132 "city": {"type": "string"},
9133 "options": {"$ref": "#/$defs/WeatherOptions"}
9134 },
9135 "required": ["city", "options"],
9136 "additionalProperties": false
9137 }
9138 }
9139 }]
9140 })
9141 }
9142
9143 #[tokio::test]
9144 async fn route_chat_decodes_xml_object_argument_through_local_schema_ref() {
9145 let template = ModelChatTemplate::new(
9146 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9147 "function-parameter-xml-template",
9148 );
9149 let response = post_json(
9150 router_with_stub_and_template(
9151 concat!(
9152 "<tool_call>\n",
9153 "<function=get_weather>\n",
9154 "<parameter=city>\nBerlin\n</parameter>\n",
9155 "<parameter=options>\n",
9156 "{\"unit\":\"celsius\",\"include_forecast\":true}\n",
9157 "</parameter>\n",
9158 "</function>\n",
9159 "</tool_call>",
9160 ),
9161 template,
9162 ),
9163 "/v1/chat/completions",
9164 xml_object_argument_tool_request(false),
9165 )
9166 .await;
9167
9168 assert_eq!(response.status(), AxumStatusCode::OK);
9169 let body = response_json(response).await;
9170 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9171 let arguments = body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
9172 .as_str()
9173 .and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
9174 .expect("tool arguments must contain one-decode structured JSON");
9175 assert_eq!(arguments["city"], json!("Berlin"));
9176 assert_eq!(
9177 arguments["options"],
9178 json!({"unit": "celsius", "include_forecast": true})
9179 );
9180 }
9181
9182 #[tokio::test]
9183 async fn route_chat_rejects_malformed_native_xml_object_argument() {
9184 let template = ModelChatTemplate::new(
9185 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9186 "function-parameter-xml-template",
9187 );
9188 let mut request = xml_object_argument_tool_request(false);
9189 request["tools"][0]["function"]["strict"] = json!(true);
9190 let response = post_json(
9191 router_with_stub_and_template(
9192 concat!(
9193 "<tool_call><function=get_weather>",
9194 "<parameter=city>Berlin</parameter>",
9195 "<parameter=options>{\"unit\":\"celsius\",</parameter>",
9196 "</function></tool_call>",
9197 ),
9198 template,
9199 ),
9200 "/v1/chat/completions",
9201 request,
9202 )
9203 .await;
9204
9205 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9206 let body = response_json(response).await;
9207 assert_eq!(body["error"]["type"], "internal_server_error");
9208 assert!(
9209 body["error"]["message"]
9210 .as_str()
9211 .is_some_and(|message| message.contains("did not satisfy its schema")),
9212 "body: {body}"
9213 );
9214 }
9215
9216 #[tokio::test]
9217 async fn route_streaming_chat_rejects_malformed_native_xml_before_tool_delta() {
9218 let template = ModelChatTemplate::new(
9219 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9220 "function-parameter-xml-template",
9221 );
9222 let mut request = xml_object_argument_tool_request(true);
9223 request["tools"][0]["function"]["strict"] = json!(true);
9224 let response = post_json(
9225 router_with_stub_and_template(
9226 concat!(
9227 "<tool_call><function=get_weather>",
9228 "<parameter=city>Berlin</parameter>",
9229 "<parameter=options>{\"unit\":\"celsius\",</parameter>",
9230 "</function></tool_call>",
9231 ),
9232 template,
9233 ),
9234 "/v1/chat/completions",
9235 request,
9236 )
9237 .await;
9238
9239 assert_eq!(response.status(), AxumStatusCode::OK);
9240 let body = response_text(response).await;
9241 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9242 assert!(
9243 body.contains(r#""error":{"#) && body.contains("did not satisfy its schema"),
9244 "stream must return a controlled schema error: {body}"
9245 );
9246 assert!(
9247 !body.contains(r#""tool_calls":[{"#),
9248 "invalid native arguments must not leak a tool delta: {body}"
9249 );
9250 }
9251
9252 #[tokio::test]
9253 async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
9254 let response = post_json(
9255 router_with_stub(
9256 "kaza\n\
9257 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
9258 </think>\n\
9259 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
9260 {\"temperature\":25,\"condition\":\"晴\"}",
9261 ),
9262 "/v1/chat/completions",
9263 json!({
9264 "model": "stub-model",
9265 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
9266 "tools": [{
9267 "type": "function",
9268 "function": {
9269 "name": "get_weather",
9270 "parameters": {
9271 "type": "object",
9272 "properties": {
9273 "city": {"type": "string"},
9274 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9275 },
9276 "required": ["city"]
9277 }
9278 }
9279 }]
9280 }),
9281 )
9282 .await;
9283 assert_eq!(response.status(), AxumStatusCode::OK);
9284 let body = response_json(response).await;
9285 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9286 assert_eq!(body["choices"][0]["message"]["content"], "");
9287 assert_eq!(
9288 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9289 "get_weather"
9290 );
9291 assert_eq!(
9292 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9293 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9294 );
9295 }
9296
9297 #[tokio::test]
9298 async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
9299 let response = post_json(
9300 router_with_stub(
9301 "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
9302 </think>\n\
9303 {\"name\":\"get_weather\",\"arguments\":{}}",
9304 ),
9305 "/v1/chat/completions",
9306 json!({
9307 "model": "stub-model",
9308 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
9309 "tools": [{
9310 "type": "function",
9311 "function": {
9312 "name": "get_weather",
9313 "parameters": {
9314 "type": "object",
9315 "properties": {
9316 "city": {"type": "string"},
9317 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
9318 },
9319 "required": ["city"]
9320 }
9321 }
9322 }]
9323 }),
9324 )
9325 .await;
9326 assert_eq!(response.status(), AxumStatusCode::OK);
9327 let body = response_json(response).await;
9328 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9329 assert_eq!(
9330 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9331 "get_weather"
9332 );
9333 assert_eq!(
9334 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9335 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
9336 );
9337 }
9338
9339 #[tokio::test]
9340 async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
9341 let response = post_json(
9342 router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
9343 "/v1/chat/completions",
9344 json!({
9345 "model": "stub-model",
9346 "messages": [{"role": "user", "content": "Use the selected tool."}],
9347 "tools": [
9348 {
9349 "type": "function",
9350 "function": {"name": "weather", "parameters": {"type": "object"}}
9351 },
9352 {
9353 "type": "function",
9354 "function": {"name": "calendar", "parameters": {"type": "object"}}
9355 }
9356 ],
9357 "tool_choice": {
9358 "type": "function",
9359 "function": {"name": "weather"}
9360 }
9361 }),
9362 )
9363 .await;
9364 assert_eq!(response.status(), AxumStatusCode::OK);
9365 let body = response_json(response).await;
9366 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9367 assert_eq!(
9368 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9369 "weather"
9370 );
9371
9372 let response = post_json(
9373 router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
9374 "/v1/chat/completions",
9375 json!({
9376 "model": "stub-model",
9377 "messages": [{"role": "user", "content": "Use the selected tool."}],
9378 "tools": [
9379 {
9380 "type": "function",
9381 "function": {"name": "weather", "parameters": {"type": "object"}}
9382 },
9383 {
9384 "type": "function",
9385 "function": {"name": "calendar", "parameters": {"type": "object"}}
9386 }
9387 ],
9388 "tool_choice": {
9389 "type": "function",
9390 "function": {"name": "weather"}
9391 }
9392 }),
9393 )
9394 .await;
9395 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9396 let body = response_json(response).await;
9397 assert_eq!(body["error"]["param"], "tool_choice");
9398 assert_eq!(body["error"]["type"], "invalid_request_error");
9399 }
9400
9401 #[tokio::test]
9402 async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
9403 let response = post_json(
9404 router_with_stub(r#"{"city":"Paris"}"#),
9405 "/v1/chat/completions",
9406 json!({
9407 "model": "stub-model",
9408 "messages": [{"role": "user", "content": "Use the selected tool."}],
9409 "tools": [{
9410 "type": "function",
9411 "function": {
9412 "name": "weather",
9413 "parameters": {
9414 "type": "object",
9415 "properties": {"city": {"type": "string"}},
9416 "required": ["city"]
9417 }
9418 }
9419 }],
9420 "tool_choice": {
9421 "type": "function",
9422 "function": {"name": "weather"}
9423 }
9424 }),
9425 )
9426 .await;
9427 assert_eq!(response.status(), AxumStatusCode::OK);
9428 let body = response_json(response).await;
9429 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9430 assert_eq!(body["choices"][0]["message"]["content"], "");
9431 assert_eq!(
9432 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9433 "weather"
9434 );
9435 assert_eq!(
9436 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9437 "{\"city\":\"Paris\"}"
9438 );
9439 }
9440
9441 #[tokio::test]
9442 async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
9443 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9444 let response = post_json(
9445 router_with_stub(generated),
9446 "/v1/chat/completions",
9447 json!({
9448 "model": "stub-model",
9449 "messages": [{"role": "user", "content": "Do not use tools."}],
9450 "tools": [{
9451 "type": "function",
9452 "function": {"name": "weather", "parameters": {"type": "object"}}
9453 }],
9454 "tool_choice": "none"
9455 }),
9456 )
9457 .await;
9458 assert_eq!(response.status(), AxumStatusCode::OK);
9459 let body = response_json(response).await;
9460 assert_eq!(body["choices"][0]["finish_reason"], "stop");
9461 assert_eq!(body["choices"][0]["message"]["content"], generated);
9462 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
9463 }
9464
9465 #[tokio::test]
9466 async fn route_chat_tool_choice_required_wraps_generated_arguments() {
9467 let response = post_json(
9468 router_with_stub(r#"{"city":"Paris"}"#),
9469 "/v1/chat/completions",
9470 json!({
9471 "model": "stub-model",
9472 "messages": [{"role": "user", "content": "Use a tool."}],
9473 "tools": [{
9474 "type": "function",
9475 "function": {
9476 "name": "weather",
9477 "parameters": {
9478 "type": "object",
9479 "properties": {"city": {"type": "string"}},
9480 "required": ["city"]
9481 }
9482 }
9483 }],
9484 "tool_choice": "required"
9485 }),
9486 )
9487 .await;
9488 assert_eq!(response.status(), AxumStatusCode::OK);
9489 let body = response_json(response).await;
9490 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9491 assert_eq!(body["choices"][0]["message"]["content"], "");
9492 assert_eq!(
9493 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9494 "weather"
9495 );
9496 assert_eq!(
9497 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9498 "{\"city\":\"Paris\"}"
9499 );
9500 }
9501
9502 fn required_tool_with_strict_response_format_request(stream: bool) -> Value {
9503 json!({
9504 "model": "stub-model",
9505 "messages": [{"role": "user", "content": "Use the weather tool."}],
9506 "stream": stream,
9507 "stream_options": stream.then_some(json!({"include_usage": true})),
9508 "tools": [{
9509 "type": "function",
9510 "function": {
9511 "name": "weather",
9512 "parameters": {
9513 "type": "object",
9514 "properties": {"city": {"type": "string", "const": "Paris"}},
9515 "required": ["city"],
9516 "additionalProperties": false
9517 }
9518 }
9519 }],
9520 "tool_choice": "required",
9521 "response_format": {
9522 "type": "json_schema",
9523 "json_schema": {
9524 "name": "content_answer",
9525 "strict": true,
9526 "schema": {
9527 "type": "object",
9528 "properties": {"answer": {"type": "string", "const": "IGNORED"}},
9529 "required": ["answer"],
9530 "additionalProperties": false
9531 }
9532 }
9533 }
9534 })
9535 }
9536
9537 #[tokio::test]
9538 async fn route_chat_required_tool_takes_priority_over_strict_response_format() {
9539 let response = post_json(
9540 router_with_stub(r#"{"city":"Paris"}"#),
9541 "/v1/chat/completions",
9542 required_tool_with_strict_response_format_request(false),
9543 )
9544 .await;
9545 assert_eq!(response.status(), AxumStatusCode::OK);
9546 let body = response_json(response).await;
9547 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
9548 assert_eq!(body["choices"][0]["message"]["content"], "");
9549 assert_eq!(
9550 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
9551 "weather"
9552 );
9553 assert_eq!(
9554 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
9555 r#"{"city":"Paris"}"#
9556 );
9557 }
9558
9559 #[tokio::test]
9560 async fn route_chat_required_tool_rejects_arguments_that_violate_const_schema() {
9561 let response = post_json(
9562 router_with_stub(r#"{"city":"London"}"#),
9563 "/v1/chat/completions",
9564 required_tool_with_strict_response_format_request(false),
9565 )
9566 .await;
9567 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
9568 let body = response_json(response).await;
9569 assert_eq!(body["error"]["type"], "internal_server_error");
9570 assert!(
9571 body["error"]["message"]
9572 .as_str()
9573 .is_some_and(|message| message.contains("did not satisfy its schema")),
9574 "body: {body}"
9575 );
9576 }
9577
9578 #[tokio::test]
9579 async fn route_streaming_required_tool_takes_priority_over_strict_response_format() {
9580 let response = post_json(
9581 router_with_stub(r#"{"city":"Paris"}"#),
9582 "/v1/chat/completions",
9583 required_tool_with_strict_response_format_request(true),
9584 )
9585 .await;
9586 assert_eq!(response.status(), AxumStatusCode::OK);
9587 let body = response_text(response).await;
9588 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9589 assert!(
9590 body.contains(r#""finish_reason":"tool_calls""#),
9591 "tool priority must finish with tool_calls: {body}"
9592 );
9593 assert!(
9594 body.contains(r#""name":"weather""#)
9595 && body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9596 "stream must carry the reconstructed tool call: {body}"
9597 );
9598 assert_eq!(
9599 body.matches(r#""usage":{"#).count(),
9600 1,
9601 "stream must carry exactly one usage row: {body}"
9602 );
9603 assert!(
9604 !body.contains("strict json_schema") && !body.contains("invalid JSON"),
9605 "dormant content schema must not reject a required tool call: {body}"
9606 );
9607 }
9608
9609 #[tokio::test]
9610 async fn dropping_buffered_http_response_drops_the_engine_stream() {
9611 let stream_dropped = Arc::new(Notify::new());
9612 let response = post_json(
9613 AxumServer::from_llm(Arc::new(StubLlm::with_pending_stream(Arc::clone(
9614 &stream_dropped,
9615 ))))
9616 .build_router(),
9617 "/v1/chat/completions",
9618 required_tool_with_strict_response_format_request(true),
9619 )
9620 .await;
9621 assert_eq!(response.status(), AxumStatusCode::OK);
9622
9623 drop(response);
9624 tokio::time::timeout(std::time::Duration::from_secs(1), stream_dropped.notified())
9625 .await
9626 .expect("client disconnect must stop a buffered structured stream promptly");
9627 }
9628
9629 #[tokio::test]
9630 async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
9631 let response = post_json(
9632 router_with_stub("plain answer"),
9633 "/v1/chat/completions",
9634 json!({
9635 "model": "stub-model",
9636 "messages": [{"role": "user", "content": "Use a tool."}],
9637 "tools": [{
9638 "type": "function",
9639 "function": {"name": "weather", "parameters": {"type": "object"}}
9640 }],
9641 "tool_choice": "required"
9642 }),
9643 )
9644 .await;
9645 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
9646 let body = response_json(response).await;
9647 assert_eq!(body["error"]["type"], "invalid_request_error");
9648 assert_eq!(body["error"]["param"], "tool_choice");
9649 assert!(
9650 body["error"]["message"]
9651 .as_str()
9652 .is_some_and(|message| message.contains("required tool_choice")),
9653 "body: {body}"
9654 );
9655 }
9656
9657 #[tokio::test]
9658 async fn route_streaming_chat_serializes_generated_tool_call_delta() {
9659 let response = post_json(
9660 router_with_stub(
9661 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
9662 ),
9663 "/v1/chat/completions",
9664 json!({
9665 "model": "stub-model",
9666 "messages": [{"role": "user", "content": "Use the weather tool."}],
9667 "stream": true,
9668 "tools": [{
9669 "type": "function",
9670 "function": {
9671 "name": "weather",
9672 "parameters": {
9673 "type": "object",
9674 "properties": {"city": {"type": "string"}},
9675 "required": ["city"]
9676 }
9677 }
9678 }],
9679 "tool_choice": "auto"
9680 }),
9681 )
9682 .await;
9683 assert_eq!(response.status(), AxumStatusCode::OK);
9684 let body = response_text(response).await;
9685 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9686 assert!(
9687 body.contains(r#""finish_reason":"tool_calls""#),
9688 "stream should finish with tool_calls: {body}"
9689 );
9690 assert!(
9691 body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
9692 "stream should emit OpenAI tool_calls delta with index: {body}"
9693 );
9694 assert!(
9695 body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
9696 "tool arguments should be serialized as JSON string: {body}"
9697 );
9698 assert!(
9699 !body.contains(r#""content":"{\"tool_calls\""#),
9700 "raw tool-call JSON should not be streamed as assistant content: {body}"
9701 );
9702 }
9703
9704 #[tokio::test]
9705 async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
9706 let response = post_json(
9707 router_with_stub(
9708 r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
9709 ),
9710 "/v1/chat/completions",
9711 json!({
9712 "model": "stub-model",
9713 "messages": [{"role": "user", "content": "深圳天气?"}],
9714 "stream": true,
9715 "tools": [{
9716 "type": "function",
9717 "function": {
9718 "name": "get_weather",
9719 "parameters": {
9720 "type": "object",
9721 "properties": {
9722 "city": {"type": "string"},
9723 "unit": {"type": "string", "enum": ["c", "f"]}
9724 },
9725 "required": ["city"]
9726 }
9727 }
9728 }],
9729 "tool_choice": "auto"
9730 }),
9731 )
9732 .await;
9733 assert_eq!(response.status(), AxumStatusCode::OK);
9734 let body = response_text(response).await;
9735 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9736 assert!(
9737 body.contains(r#""finish_reason":"tool_calls""#),
9738 "stream should finish with tool_calls: {body}"
9739 );
9740 assert!(
9741 body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
9742 "stream should emit parsed Qwen3 function parameters as tool args: {body}"
9743 );
9744 assert!(
9745 !body.contains(r#""content":"{\"function\""#),
9746 "raw Qwen3 tool JSON should not leak as assistant content: {body}"
9747 );
9748 }
9749
9750 #[tokio::test]
9751 async fn route_streaming_chat_preserves_opencode_edit_xml_whitespace() {
9752 let template = ModelChatTemplate::new(
9753 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% for message in messages %}{{ message.content }}{% endfor %}",
9754 "function-parameter-xml-template",
9755 );
9756 let generated = concat!(
9757 "<tool_call>\n",
9758 "<function=edit>\n",
9759 "<parameter=filePath>\n",
9760 "/workspace/src/main.rs\n",
9761 "</parameter>\n",
9762 "<parameter=oldString>\n",
9763 " if x:\n",
9764 " return 1\n",
9765 "\n",
9766 "</parameter>\n",
9767 "<parameter=newString>\n",
9768 " if x:\n",
9769 " return 2\n",
9770 "\n",
9771 "</parameter>\n",
9772 "<parameter=replaceAll>\n",
9773 "true\n",
9774 "</parameter>\n",
9775 "</function>\n",
9776 "</tool_call>",
9777 );
9778 let response = post_json(
9779 router_with_stub_and_template(generated, template),
9780 "/v1/chat/completions",
9781 json!({
9782 "model": "stub-model",
9783 "messages": [{"role": "user", "content": "Replace the code."}],
9784 "stream": true,
9785 "tools": [{
9786 "type": "function",
9787 "function": {
9788 "name": "edit",
9789 "parameters": {
9790 "type": "object",
9791 "properties": {
9792 "filePath": {"type": "string"},
9793 "oldString": {"type": "string"},
9794 "newString": {"type": "string"},
9795 "replaceAll": {"type": "boolean"}
9796 },
9797 "required": ["filePath", "oldString", "newString"]
9798 }
9799 }
9800 }]
9801 }),
9802 )
9803 .await;
9804
9805 assert_eq!(response.status(), AxumStatusCode::OK);
9806 let body = response_text(response).await;
9807 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9808 assert!(
9809 body.contains(r#"\"oldString\":\" if x:\\n return 1\\n\""#),
9810 "stream must preserve exact code whitespace in tool arguments: {body}"
9811 );
9812 assert!(
9813 body.contains(r#"\"replaceAll\":true"#),
9814 "stream must preserve the boolean tool argument type: {body}"
9815 );
9816 }
9817
9818 #[tokio::test]
9819 async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
9820 let request = |generated: &'static str| {
9821 post_json(
9822 router_with_stub(generated),
9823 "/v1/chat/completions",
9824 json!({
9825 "model": "stub-model",
9826 "messages": [{"role": "user", "content": "Use the selected tool."}],
9827 "stream": true,
9828 "tools": [
9829 {
9830 "type": "function",
9831 "function": {"name": "weather", "parameters": {"type": "object"}}
9832 },
9833 {
9834 "type": "function",
9835 "function": {"name": "calendar", "parameters": {"type": "object"}}
9836 }
9837 ],
9838 "tool_choice": {
9839 "type": "function",
9840 "function": {"name": "weather"}
9841 }
9842 }),
9843 )
9844 };
9845
9846 let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
9847 assert_eq!(response.status(), AxumStatusCode::OK);
9848 let body = response_text(response).await;
9849 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9850 assert!(
9851 body.contains(r#""finish_reason":"tool_calls""#),
9852 "selected tool should finish with tool_calls: {body}"
9853 );
9854 assert!(
9855 body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
9856 "selected tool should stream as tool_calls delta: {body}"
9857 );
9858
9859 let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
9860 assert_eq!(response.status(), AxumStatusCode::OK);
9861 let body = response_text(response).await;
9862 assert!(
9863 body.contains(
9864 r#""error":{"message":"model output did not satisfy required tool_choice""#
9865 ),
9866 "selected-tool stream should reject unselected tool output: {body}"
9867 );
9868 assert!(
9869 !body.contains(r#""finish_reason":"tool_calls""#),
9870 "unselected tool JSON must not become tool_calls: {body}"
9871 );
9872 }
9873
9874 #[tokio::test]
9875 async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
9876 let response = post_json(
9877 router_with_stub_api_response(
9878 "raw text that should not stream",
9879 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
9880 message: ferrum_types::ApiChatMessage {
9881 role: ferrum_types::ApiMessageRole::Assistant,
9882 content: String::new(),
9883 name: None,
9884 tool_calls: vec![ferrum_types::ApiToolCall {
9885 id: "call_1".to_string(),
9886 tool_type: "function".to_string(),
9887 function: ferrum_types::ApiFunctionCall {
9888 name: "weather".to_string(),
9889 arguments: "{\"city\":\"Paris\"}".to_string(),
9890 },
9891 }],
9892 tool_call_id: None,
9893 function_call: None,
9894 },
9895 finish_reason: Some("tool_calls".to_string()),
9896 }),
9897 ),
9898 "/v1/chat/completions",
9899 json!({
9900 "model": "stub-model",
9901 "messages": [{"role": "user", "content": "Use the weather tool."}],
9902 "stream": true,
9903 "tools": [{
9904 "type": "function",
9905 "function": {"name": "weather", "parameters": {"type": "object"}}
9906 }],
9907 "tool_choice": "auto"
9908 }),
9909 )
9910 .await;
9911 assert_eq!(response.status(), AxumStatusCode::OK);
9912 let body = response_text(response).await;
9913 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9914 assert!(
9915 body.contains(r#""finish_reason":"tool_calls""#),
9916 "stream should finish with tool_calls: {body}"
9917 );
9918 assert!(
9919 body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
9920 "stream should emit tool_calls from chunk api_response: {body}"
9921 );
9922 assert!(
9923 !body.contains("raw text that should not stream"),
9924 "structured api_response should suppress raw generated text in tool-call stream: {body}"
9925 );
9926 }
9927
9928 #[tokio::test]
9929 async fn route_streaming_chat_preserves_length_over_structured_tool_response() {
9930 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
9931 let response = post_json(
9932 router_with_stub_api_response_and_finish_reason(
9933 generated,
9934 weather_tool_api_response(),
9935 FinishReason::Length,
9936 ),
9937 "/v1/chat/completions",
9938 json!({
9939 "model": "stub-model",
9940 "messages": [{"role": "user", "content": "Use the weather tool."}],
9941 "stream": true,
9942 "tools": [{
9943 "type": "function",
9944 "function": {"name": "weather", "parameters": {"type": "object"}}
9945 }],
9946 "tool_choice": "auto"
9947 }),
9948 )
9949 .await;
9950 assert_eq!(response.status(), AxumStatusCode::OK);
9951 let body = response_text(response).await;
9952 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
9953 assert!(
9954 body.contains(r#""finish_reason":"length""#),
9955 "stream must preserve the engine terminal reason: {body}"
9956 );
9957 assert!(
9958 !body.contains(r#""finish_reason":"tool_calls""#),
9959 "length must not be relabeled as tool_calls: {body}"
9960 );
9961 }
9962
9963 #[tokio::test]
9964 async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
9965 let response = post_json(
9966 router_with_stub("plain answer"),
9967 "/v1/chat/completions",
9968 json!({
9969 "model": "stub-model",
9970 "messages": [{"role": "user", "content": "Use a tool."}],
9971 "stream": true,
9972 "tools": [{
9973 "type": "function",
9974 "function": {"name": "weather", "parameters": {"type": "object"}}
9975 }],
9976 "tool_choice": "required"
9977 }),
9978 )
9979 .await;
9980 assert_eq!(response.status(), AxumStatusCode::OK);
9981 let body = response_text(response).await;
9982 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
9983 assert!(
9984 body.contains(
9985 r#""error":{"message":"model output did not satisfy required tool_choice""#
9986 ),
9987 "stream should emit OpenAI error envelope: {body}"
9988 );
9989 assert!(
9990 body.contains(r#""type":"invalid_request_error""#),
9991 "stream should use invalid_request_error: {body}"
9992 );
9993 assert!(
9994 body.contains(r#""param":"tool_choice""#),
9995 "stream should include tool_choice param: {body}"
9996 );
9997 assert!(
9998 !body.contains(r#""content":"plain answer""#),
9999 "required stream must not leak invalid content before validation: {body}"
10000 );
10001 }
10002
10003 #[tokio::test]
10004 async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
10005 let response = post_json(
10006 router_with_stub("plain answer"),
10007 "/v1/chat/completions",
10008 json!({
10009 "model": "stub-model",
10010 "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
10011 "stream": true,
10012 "tools": [{
10013 "type": "function",
10014 "function": {"name": "weather", "parameters": {"type": "object"}}
10015 }],
10016 "tool_choice": "auto"
10017 }),
10018 )
10019 .await;
10020 assert_eq!(response.status(), AxumStatusCode::OK);
10021 let body = response_text(response).await;
10022 assert!(
10023 body.contains(r#""content":"plain answer""#),
10024 "plain content should still stream when no tool call is generated: {body}"
10025 );
10026 assert!(
10027 body.contains(r#""finish_reason":"stop""#),
10028 "plain content should keep normal finish reason: {body}"
10029 );
10030 assert!(
10031 !body.contains(r#""tool_calls""#),
10032 "fallback content should not synthesize tool_calls: {body}"
10033 );
10034 }
10035
10036 #[tokio::test]
10037 async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
10038 let response = post_json(
10039 router_with_stub(
10040 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
10041 ),
10042 "/v1/chat/completions",
10043 json!({
10044 "model": "stub-model",
10045 "messages": [{"role": "user", "content": "Use the weather function."}],
10046 "stream": true,
10047 "functions": [{
10048 "name": "weather",
10049 "parameters": {
10050 "type": "object",
10051 "properties": {"city": {"type": "string"}},
10052 "required": ["city"]
10053 }
10054 }],
10055 "function_call": "auto"
10056 }),
10057 )
10058 .await;
10059 assert_eq!(response.status(), AxumStatusCode::OK);
10060 let body = response_text(response).await;
10061 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10062 assert!(
10063 body.contains(r#""finish_reason":"function_call""#),
10064 "stream should finish with function_call: {body}"
10065 );
10066 assert!(
10067 body.contains(
10068 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
10069 ),
10070 "stream should emit OpenAI legacy function_call delta: {body}"
10071 );
10072 assert!(
10073 !body.contains(r#""content":"{\"function_call\""#),
10074 "raw function-call JSON should not be streamed as assistant content: {body}"
10075 );
10076 }
10077
10078 #[tokio::test]
10079 async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
10080 let request = |generated: &'static str| {
10081 post_json(
10082 router_with_stub(generated),
10083 "/v1/chat/completions",
10084 json!({
10085 "model": "stub-model",
10086 "messages": [{"role": "user", "content": "Use the selected function."}],
10087 "stream": true,
10088 "functions": [
10089 {"name": "weather", "parameters": {"type": "object"}},
10090 {"name": "calendar", "parameters": {"type": "object"}}
10091 ],
10092 "function_call": {"name": "weather"}
10093 }),
10094 )
10095 };
10096
10097 let response =
10098 request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
10099 assert_eq!(response.status(), AxumStatusCode::OK);
10100 let body = response_text(response).await;
10101 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10102 assert!(
10103 body.contains(r#""finish_reason":"function_call""#),
10104 "selected function should finish with function_call: {body}"
10105 );
10106 assert!(
10107 body.contains(
10108 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
10109 ),
10110 "selected function should stream as function_call delta: {body}"
10111 );
10112
10113 let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
10114 assert_eq!(response.status(), AxumStatusCode::OK);
10115 let body = response_text(response).await;
10116 assert!(
10117 body.contains(
10118 r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
10119 ),
10120 "unselected function JSON should stream as ordinary content: {body}"
10121 );
10122 assert!(
10123 body.contains(r#""finish_reason":"stop""#),
10124 "unselected function JSON should keep normal stop finish: {body}"
10125 );
10126 assert!(
10127 !body.contains(r#""finish_reason":"function_call""#),
10128 "unselected function JSON must not become function_call: {body}"
10129 );
10130 }
10131
10132 #[tokio::test]
10133 async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
10134 let response = post_json(
10135 router_with_stub(
10136 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
10137 ),
10138 "/v1/chat/completions",
10139 json!({
10140 "model": "stub-model",
10141 "messages": [{"role": "user", "content": "Use the weather function."}],
10142 "functions": [{
10143 "name": "weather",
10144 "parameters": {
10145 "type": "object",
10146 "properties": {"city": {"type": "string"}},
10147 "required": ["city"]
10148 }
10149 }],
10150 "function_call": "auto"
10151 }),
10152 )
10153 .await;
10154 assert_eq!(response.status(), AxumStatusCode::OK);
10155 let body = response_json(response).await;
10156 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
10157 assert_eq!(body["choices"][0]["message"]["content"], "");
10158 assert_eq!(
10159 body["choices"][0]["message"]["function_call"]["name"],
10160 "weather"
10161 );
10162 assert_eq!(
10163 body["choices"][0]["message"]["function_call"]["arguments"],
10164 "{\"city\":\"Paris\"}"
10165 );
10166 }
10167
10168 #[tokio::test]
10169 async fn route_chat_serializes_legacy_function_call_response() {
10170 let response = post_json(
10171 router_with_stub_api_response(
10172 "",
10173 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
10174 message: ferrum_types::ApiChatMessage {
10175 role: ferrum_types::ApiMessageRole::Assistant,
10176 content: String::new(),
10177 name: None,
10178 tool_calls: vec![],
10179 tool_call_id: None,
10180 function_call: Some(ferrum_types::ApiFunctionCall {
10181 name: "weather".to_string(),
10182 arguments: "{\"city\":\"Paris\"}".to_string(),
10183 }),
10184 },
10185 finish_reason: Some("function_call".to_string()),
10186 }),
10187 ),
10188 "/v1/chat/completions",
10189 json!({
10190 "model": "stub-model",
10191 "messages": [{"role": "user", "content": "Use the weather function."}],
10192 "functions": [{
10193 "name": "weather",
10194 "parameters": {
10195 "type": "object",
10196 "properties": {"city": {"type": "string"}},
10197 "required": ["city"]
10198 }
10199 }],
10200 "function_call": "auto"
10201 }),
10202 )
10203 .await;
10204 assert_eq!(response.status(), AxumStatusCode::OK);
10205 let body = response_json(response).await;
10206 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
10207 assert_eq!(
10208 body["choices"][0]["message"]["function_call"]["name"],
10209 "weather"
10210 );
10211 assert_eq!(
10212 body["choices"][0]["message"]["function_call"]["arguments"],
10213 "{\"city\":\"Paris\"}"
10214 );
10215 }
10216
10217 #[tokio::test]
10218 async fn route_streaming_chat_include_usage_contract() {
10219 let response = post_json(
10220 router_with_stub("ok"),
10221 "/v1/chat/completions",
10222 json!({
10223 "model": "stub-model",
10224 "messages": [{"role": "user", "content": "Say ok"}],
10225 "stream": true,
10226 "stream_options": {"include_usage": true}
10227 }),
10228 )
10229 .await;
10230 assert_eq!(response.status(), AxumStatusCode::OK);
10231 let body = response_text(response).await;
10232 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
10233 assert!(
10234 body.contains("\"object\":\"chat.completion.chunk\""),
10235 "missing chat chunk: {body}"
10236 );
10237 assert!(
10238 body.contains("\"usage\":{\"prompt_tokens\""),
10239 "missing final usage chunk: {body}"
10240 );
10241 assert!(
10242 body.contains("\"choices\":[],\"usage\""),
10243 "usage should be emitted as a separate chunk: {body}"
10244 );
10245 assert!(
10246 body.contains("\"prompt_tokens\":5"),
10247 "stream usage should come from engine token usage: {body}"
10248 );
10249 }
10250
10251 #[tokio::test]
10252 async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
10253 let response = post_json(
10254 router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
10255 "/v1/chat/completions",
10256 json!({
10257 "model": "stub-model",
10258 "messages": [{"role": "user", "content": "Say hello"}],
10259 "max_tokens": 2,
10260 "stream": true,
10261 "stream_options": {"include_usage": true}
10262 }),
10263 )
10264 .await;
10265 assert_eq!(response.status(), AxumStatusCode::OK);
10266 let body = response_text(response).await;
10267 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10268 assert!(
10269 body.contains("\"content\":\"he\""),
10270 "missing first chunk: {body}"
10271 );
10272 assert!(
10273 body.contains("\"content\":\"llo\""),
10274 "missing second chunk: {body}"
10275 );
10276 assert!(
10277 body.contains("\"choices\":[],\"usage\""),
10278 "missing separate usage chunk from final engine chunk: {body}"
10279 );
10280 assert!(
10281 body.contains("\"prompt_tokens\":5"),
10282 "stream usage should come from engine final usage: {body}"
10283 );
10284 }
10285
10286 #[tokio::test]
10287 async fn route_streaming_preserves_tokenless_tail_before_terminal() {
10288 for (path, chunks, expected_content, expected_reasoning) in [
10289 ("/v1/chat/completions", ["hello ", "尾"], "hello 尾", ""),
10290 (
10291 "/v1/chat/completions",
10292 ["<think>reason", "</think>"],
10293 "",
10294 "reason",
10295 ),
10296 ("/v1/completions", ["hello ", "尾"], "hello 尾", ""),
10297 ] {
10298 let chat = path == "/v1/chat/completions";
10299 let mut request = json!({"model": "stub-model", "stream": true});
10300 if chat {
10301 request["messages"] = json!([{"role": "user", "content": "hello"}]);
10302 request["stream_options"] = json!({"include_usage": true});
10303 } else {
10304 request["prompt"] = json!("hello");
10305 }
10306 let router = AxumServer::from_llm(Arc::new(StubLlm::with_tokenless_tail(&chunks)))
10307 .build_router();
10308 let response = post_json(router, path, request).await;
10309 assert_eq!(response.status(), AxumStatusCode::OK);
10310 let body = response_text(response).await;
10311 let events = responses_sse_json_events(&body);
10312 let content: String = events
10313 .iter()
10314 .filter_map(|event| {
10315 if chat {
10316 event["choices"][0]["delta"]["content"].as_str()
10317 } else {
10318 event["choices"][0]["text"].as_str()
10319 }
10320 })
10321 .collect();
10322 let reasoning: String = events
10323 .iter()
10324 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
10325 .collect();
10326 assert_eq!(content, expected_content, "body: {body}");
10327 assert_eq!(reasoning, expected_reasoning, "body: {body}");
10328 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
10329 assert_eq!(
10330 events
10331 .iter()
10332 .filter(|event| event["choices"][0]["finish_reason"] == "stop")
10333 .count(),
10334 1,
10335 "body: {body}"
10336 );
10337 let usage: Vec<_> = events
10338 .iter()
10339 .filter_map(|event| event["usage"].as_object())
10340 .collect();
10341 assert_eq!(usage.len(), 1, "body: {body}");
10342 assert_eq!(usage[0]["prompt_tokens"], 5);
10343 assert_eq!(usage[0]["completion_tokens"], 2);
10344 }
10345 }
10346
10347 #[tokio::test]
10348 async fn route_rejects_multimodal_content_with_400() {
10349 for content in [
10350 json!([{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}]),
10351 json!([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]),
10352 json!([{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}]),
10353 json!([
10354 {"type": "text", "text": "describe this"},
10355 {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
10356 ]),
10357 ] {
10358 let response = post_json(
10359 router_with_stub("unused"),
10360 "/v1/chat/completions",
10361 json!({
10362 "model": "stub-model",
10363 "messages": [{"role": "user", "content": content}]
10364 }),
10365 )
10366 .await;
10367 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10368 let body = response_json(response).await;
10369 assert_eq!(body["error"]["type"], "invalid_request_error");
10370 let message = body["error"]["message"].as_str().unwrap();
10371 assert!(message.contains("invalid chat completions request"));
10372 assert!(
10373 message.contains("unsupported message content part type"),
10374 "body: {body}"
10375 );
10376 }
10377 }
10378
10379 #[tokio::test]
10380 async fn route_rejects_non_object_stream_options() {
10381 for stream_options in [json!([]), json!("yes"), json!(42), json!(true)] {
10382 let response = post_json(
10383 router_with_stub("unused"),
10384 "/v1/chat/completions",
10385 json!({
10386 "model": "stub-model",
10387 "messages": [{"role": "user", "content": "hello"}],
10388 "stream": true,
10389 "stream_options": stream_options
10390 }),
10391 )
10392 .await;
10393 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10394 let body = response_json(response).await;
10395 assert_eq!(body["error"]["type"], "invalid_request_error");
10396 assert!(
10397 body["error"]["message"]
10398 .as_str()
10399 .unwrap_or_default()
10400 .contains("stream_options must be a JSON object"),
10401 "body: {body}"
10402 );
10403 }
10404 }
10405
10406 #[tokio::test]
10407 async fn route_accepts_text_only_content_array() {
10408 let response = post_json(
10409 router_with_stub("ok"),
10410 "/v1/chat/completions",
10411 json!({
10412 "model": "stub-model",
10413 "messages": [{
10414 "role": "user",
10415 "content": [
10416 {"type": "text", "text": "say"},
10417 {"type": "text", "text": "ok"}
10418 ]
10419 }]
10420 }),
10421 )
10422 .await;
10423 assert_eq!(response.status(), AxumStatusCode::OK);
10424 let body = response_json(response).await;
10425 assert_eq!(body["choices"][0]["message"]["content"], "ok");
10426 }
10427
10428 #[tokio::test]
10429 async fn route_chat_invalid_json_maps_to_openai_error() {
10430 let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
10431 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10432 let body = response_json(response).await;
10433 assert_eq!(body["error"]["type"], "invalid_request_error");
10434 assert_eq!(body["error"]["param"], Value::Null);
10435 assert!(body["error"]["message"]
10436 .as_str()
10437 .unwrap()
10438 .contains("invalid chat completions request"));
10439 }
10440
10441 #[tokio::test]
10442 async fn route_rejects_logit_bias_with_openai_error_param() {
10443 let response = post_json(
10444 router_with_stub("unused"),
10445 "/v1/chat/completions",
10446 json!({
10447 "model": "stub-model",
10448 "messages": [{"role": "user", "content": "hello"}],
10449 "logit_bias": {"1": 42.0}
10450 }),
10451 )
10452 .await;
10453 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
10454 let body = response_json(response).await;
10455 assert_eq!(body["error"]["type"], "invalid_request_error");
10456 assert_eq!(body["error"]["param"], "logit_bias");
10457 }
10458
10459 #[tokio::test]
10460 async fn route_tool_request_reaches_engine_structured_boundary() {
10461 for stream in [false, true] {
10462 let (router, engine) = router_with_capturing_llm();
10463 let response = post_json(
10464 router,
10465 "/v1/chat/completions",
10466 json!({
10467 "model": "qwen3",
10468 "messages": [
10469 {"role": "user", "content": "Use the weather tool."},
10470 {
10471 "role": "assistant",
10472 "content": null,
10473 "tool_calls": [{
10474 "id": "call_1",
10475 "type": "function",
10476 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10477 }]
10478 },
10479 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
10480 ],
10481 "tools": [{
10482 "type": "function",
10483 "function": {
10484 "name": "weather",
10485 "description": "Get weather",
10486 "parameters": {
10487 "type": "object",
10488 "properties": {"city": {"type": "string"}},
10489 "required": ["city"]
10490 }
10491 }
10492 }],
10493 "tool_choice": "auto",
10494 "functions": [{
10495 "name": "legacy_weather",
10496 "parameters": {"type": "object", "properties": {}}
10497 }],
10498 "function_call": "auto",
10499 "stream": stream
10500 }),
10501 )
10502 .await;
10503 assert_eq!(response.status(), AxumStatusCode::OK);
10504
10505 if stream {
10506 let body = response_text(response).await;
10507 assert!(body.contains("[DONE]"), "{body}");
10508 assert!(body.contains("captured"), "{body}");
10509 } else {
10510 let body = response_json(response).await;
10511 assert_eq!(body["choices"][0]["message"]["content"], "captured");
10512 assert_eq!(body["choices"][0]["finish_reason"], "stop");
10513 }
10514 let request = engine.last_request();
10515 assert!(request.prompt.contains("\"tools\":[{"));
10516 assert!(request.prompt.contains("\"type\":\"function\""));
10517 assert!(request.prompt.contains("\"name\":\"weather\""));
10518 assert!(request.prompt.contains("<|im_start|>assistant\n{"));
10519 assert!(request.prompt.contains("\"tool_calls\":[{"));
10520 assert!(request.prompt.contains("\"id\":\"call_1\""));
10521 assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
10522 assert_eq!(
10523 request.metadata["openai_tools"][0]["function"]["name"],
10524 "weather"
10525 );
10526 assert_eq!(request.metadata["openai_tool_choice"], "auto");
10527 assert_eq!(
10528 request.metadata["openai_legacy_functions"][0]["name"],
10529 "legacy_weather"
10530 );
10531 assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
10532 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10533 panic!("expected structured chat api_request");
10534 };
10535 assert_eq!(api.messages.len(), 3);
10536 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
10537 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
10538 assert_eq!(api.messages[1].tool_calls[0].id, "call_1");
10539 assert_eq!(api.messages[1].tool_calls[0].function.name, "weather");
10540 assert_eq!(api.messages[2].content, "sunny");
10541 assert_eq!(api.tools[0].function.name, "weather");
10542 assert_eq!(api.legacy_functions[0].name, "legacy_weather");
10543 assert_eq!(
10544 api.messages[1].tool_calls[0].function.arguments,
10545 "{\"city\":\"Paris\"}"
10546 );
10547 }
10548 }
10549
10550 #[tokio::test]
10551 async fn route_replays_reasoning_content_in_qwen36_tool_history_sync() {
10552 let compatibility = capture_qwen36_tool_history_request(
10553 json!({"reasoning_content": "opencode-reasoning-marker"}),
10554 false,
10555 )
10556 .await;
10557 let canonical = capture_qwen36_tool_history_request(
10558 json!({"reasoning": "opencode-reasoning-marker"}),
10559 false,
10560 )
10561 .await;
10562
10563 assert_eq!(compatibility.prompt, canonical.prompt);
10564 assert!(
10565 compatibility.prompt.contains("opencode-reasoning-marker"),
10566 "Qwen3.6 prompt dropped assistant reasoning history: {}",
10567 compatibility.prompt
10568 );
10569 let message = &compatibility.metadata["openai_messages"][1];
10570 assert_eq!(message["reasoning"], "opencode-reasoning-marker");
10571 assert!(message.get("reasoning_content").is_none());
10572 }
10573
10574 #[tokio::test]
10575 async fn route_replays_reasoning_content_in_qwen36_tool_history_stream() {
10576 let request = capture_qwen36_tool_history_request(
10577 json!({"reasoning_content": "opencode-stream-reasoning-marker"}),
10578 true,
10579 )
10580 .await;
10581 assert!(
10582 request.prompt.contains("opencode-stream-reasoning-marker"),
10583 "Qwen3.6 streaming prompt dropped assistant reasoning history: {}",
10584 request.prompt
10585 );
10586 }
10587
10588 #[tokio::test]
10589 async fn route_prefers_canonical_reasoning_in_qwen36_tool_history() {
10590 for stream in [false, true] {
10591 for reasoning in ["canonical-history-marker", ""] {
10592 let request = capture_qwen36_tool_history_request(
10593 json!({
10594 "reasoning": reasoning,
10595 "reasoning_content": "alias-history-marker"
10596 }),
10597 stream,
10598 )
10599 .await;
10600 let canonical =
10601 capture_qwen36_tool_history_request(json!({"reasoning": reasoning}), stream)
10602 .await;
10603 assert_eq!(request.prompt, canonical.prompt);
10604 assert!(!request.prompt.contains("alias-history-marker"));
10605 let message = &request.metadata["openai_messages"][1];
10606 assert_eq!(message["reasoning"], reasoning);
10607 assert!(message.get("reasoning_content").is_none());
10608 }
10609 }
10610 }
10611
10612 #[tokio::test]
10613 async fn route_does_not_force_reasoning_into_templates_that_ignore_it() {
10614 let template = ModelChatTemplate::new(
10615 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}",
10616 "content-only-template",
10617 );
10618 let (router, engine) = router_with_capturing_llm_and_template(template);
10619 let response = post_json(
10620 router,
10621 "/v1/chat/completions",
10622 json!({
10623 "model": "served-alias",
10624 "messages": [
10625 {"role": "user", "content": "hello"},
10626 {
10627 "role": "assistant",
10628 "content": "visible answer",
10629 "reasoning_content": "hidden-reasoning-marker"
10630 },
10631 {"role": "user", "content": "continue"}
10632 ]
10633 }),
10634 )
10635 .await;
10636 assert_eq!(response.status(), AxumStatusCode::OK);
10637
10638 let request = engine.last_request();
10639 assert!(request.prompt.contains("visible answer"));
10640 assert!(!request.prompt.contains("hidden-reasoning-marker"));
10641 }
10642
10643 #[tokio::test]
10644 async fn route_tool_request_prefers_model_chat_template() {
10645 for stream in [false, true] {
10646 let template = ModelChatTemplate::new(
10647 "{% 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 %}",
10648 "tool-template",
10649 );
10650 let (router, engine) = router_with_capturing_llm_and_template(template);
10651 let response = post_json(
10652 router,
10653 "/v1/chat/completions",
10654 json!({
10655 "model": "served-alias",
10656 "messages": [
10657 {"role": "user", "content": "Use the weather tool."},
10658 {
10659 "role": "assistant",
10660 "content": null,
10661 "tool_calls": [{
10662 "id": "weather_paris",
10663 "type": "function",
10664 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
10665 }, {
10666 "id": "weather_rome",
10667 "type": "function",
10668 "function": {"name": "weather", "arguments": "{\"city\":\"Rome\"}"}
10669 }]
10670 },
10671 {"role": "tool", "tool_call_id": "weather_rome", "content": "rainy"},
10674 {"role": "tool", "tool_call_id": "weather_paris", "content": "sunny"}
10675 ],
10676 "tools": [{
10677 "type": "function",
10678 "function": {
10679 "name": "weather",
10680 "description": "Get weather",
10681 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
10682 }
10683 }],
10684 "tool_choice": "auto",
10685 "stream": stream
10686 }),
10687 )
10688 .await;
10689 assert_eq!(response.status(), AxumStatusCode::OK);
10690
10691 if stream {
10692 let body = response_text(response).await;
10693 assert!(body.contains("[DONE]"), "{body}");
10694 assert!(body.contains("captured"), "{body}");
10695 } else {
10696 let body = response_json(response).await;
10697 assert_eq!(body["choices"][0]["message"]["content"], "captured");
10698 assert_eq!(body["choices"][0]["finish_reason"], "stop");
10699 }
10700 let request = engine.last_request();
10701 assert!(request.prompt.contains("<tools>weather</tools>"));
10702 assert!(
10703 request
10704 .prompt
10705 .contains("<tool_call id=\"weather_paris\">weather:"),
10706 "{}",
10707 request.prompt
10708 );
10709 assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
10710 assert!(request.prompt.contains("Paris"), "{}", request.prompt);
10711 assert!(request
10712 .prompt
10713 .contains("<tool_response id=\"weather_paris\">sunny</tool_response>"));
10714 assert!(request
10715 .prompt
10716 .contains("<tool_call id=\"weather_rome\">weather:"));
10717 assert!(request.prompt.contains("Rome"));
10718 assert!(request
10719 .prompt
10720 .contains("<tool_response id=\"weather_rome\">rainy</tool_response>"));
10721 assert!(request.prompt.ends_with("[assistant]"));
10722 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
10723 panic!("expected structured continuation request");
10724 };
10725 assert_eq!(api.messages.len(), 4);
10726 assert_eq!(api.messages[1].tool_calls.len(), 2);
10727 for (call, id, city) in [
10728 (&api.messages[1].tool_calls[0], "weather_paris", "Paris"),
10729 (&api.messages[1].tool_calls[1], "weather_rome", "Rome"),
10730 ] {
10731 assert_eq!(call.id, id);
10732 assert_eq!(call.function.name, "weather");
10733 let args: Value = serde_json::from_str(&call.function.arguments).unwrap();
10734 assert_eq!(args, json!({"city": city}));
10735 let prefix = format!("<tool_call id=\"{id}\">weather:");
10736 let rendered_arguments = request
10737 .prompt
10738 .split_once(&prefix)
10739 .unwrap()
10740 .1
10741 .split_once("</tool_call>")
10742 .unwrap()
10743 .0;
10744 let rendered: Value = serde_json::from_str(rendered_arguments).unwrap();
10745 assert_eq!(
10746 rendered,
10747 json!({"city": city}),
10748 "tool arguments lost their call ID binding"
10749 );
10750 }
10751 for (message, id, content) in [
10752 (&api.messages[2], "weather_rome", "rainy"),
10753 (&api.messages[3], "weather_paris", "sunny"),
10754 ] {
10755 assert_eq!(message.role, ferrum_types::ApiMessageRole::Tool);
10756 assert_eq!(message.tool_call_id.as_deref(), Some(id));
10757 assert_eq!(message.content, content);
10758 }
10759 assert!(
10760 !request.prompt.contains("<|assistant|>"),
10761 "model-template tool prompt should not use generic fallback: {}",
10762 request.prompt
10763 );
10764 assert!(
10765 !request.prompt.contains("When a tool is needed"),
10766 "model-template tool prompt should not inject fallback tool instructions: {}",
10767 request.prompt
10768 );
10769 }
10770 }
10771
10772 #[tokio::test]
10773 async fn chat_omitted_output_budget_uses_auto_ceiling() {
10774 let (router, engine) = router_with_capturing_llm();
10775 let response = post_json(
10776 router,
10777 "/v1/chat/completions",
10778 json!({
10779 "model": "stub-model",
10780 "messages": [{"role": "user", "content": "hello"}]
10781 }),
10782 )
10783 .await;
10784 assert_eq!(response.status(), AxumStatusCode::OK);
10785
10786 let request = engine.last_request();
10787 assert_eq!(request.sampling_params.max_tokens, 4096);
10788 assert_eq!(
10789 request.metadata.get(DEFAULT_MAX_TOKENS_METADATA_KEY),
10790 Some(&serde_json::json!(true))
10791 );
10792 }
10793
10794 #[tokio::test]
10795 async fn chat_accepts_stop_string_and_max_completion_tokens() {
10796 let (router, engine) = router_with_capturing_llm();
10797 let response = post_json(
10798 router,
10799 "/v1/chat/completions",
10800 json!({
10801 "model": "stub-model",
10802 "messages": [{"role": "user", "content": "hello"}],
10803 "max_tokens": 99,
10804 "max_completion_tokens": 3,
10805 "stop": "<END>"
10806 }),
10807 )
10808 .await;
10809 assert_eq!(response.status(), AxumStatusCode::OK);
10810
10811 let request = engine.last_request();
10812 let defaults = default_chat_sampling_params();
10813 assert_eq!(request.sampling_params.max_tokens, 3);
10814 assert!(!request
10815 .metadata
10816 .contains_key(DEFAULT_MAX_TOKENS_METADATA_KEY));
10817 assert_eq!(request.sampling_params.temperature, defaults.temperature);
10818 assert_eq!(
10819 request.sampling_params.repetition_penalty,
10820 defaults.repetition_penalty
10821 );
10822 assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
10823 }
10824
10825 #[tokio::test]
10826 async fn chat_maps_vllm_sampling_extensions_without_hidden_defaults() {
10827 let (router, engine) = router_with_capturing_llm();
10828 let response = post_json(
10829 router,
10830 "/v1/chat/completions",
10831 json!({
10832 "model": "stub-model",
10833 "messages": [{"role": "user", "content": "hello"}],
10834 "top_k": 20,
10835 "min_p": 0.05,
10836 "repetition_penalty": 1.25
10837 }),
10838 )
10839 .await;
10840 assert_eq!(response.status(), AxumStatusCode::OK);
10841
10842 let request = engine.last_request();
10843 assert_eq!(request.sampling_params.top_k, Some(20));
10844 assert_eq!(request.sampling_params.min_p, Some(0.05));
10845 assert_eq!(request.sampling_params.repetition_penalty, 1.25);
10846 }
10847
10848 #[tokio::test]
10849 async fn chat_normalizes_disabled_sampling_extensions_and_rejects_invalid_ranges() {
10850 let (router, engine) = router_with_capturing_llm();
10851 let response = post_json(
10852 router,
10853 "/v1/chat/completions",
10854 json!({
10855 "model": "stub-model",
10856 "messages": [{"role": "user", "content": "hello"}],
10857 "top_k": -1,
10858 "min_p": 0.0,
10859 "repetition_penalty": 1.0
10860 }),
10861 )
10862 .await;
10863 assert_eq!(response.status(), AxumStatusCode::OK);
10864 let request = engine.last_request();
10865 assert_eq!(request.sampling_params.top_k, None);
10866 assert_eq!(request.sampling_params.min_p, None);
10867 assert_eq!(request.sampling_params.repetition_penalty, 1.0);
10868
10869 for (field, value) in [
10870 ("top_k", json!(-2)),
10871 ("min_p", json!(1.01)),
10872 ("repetition_penalty", json!(0.0)),
10873 ("presence_penalty", json!(2.01)),
10874 ("frequency_penalty", json!(-2.01)),
10875 ] {
10876 let (router, _) = router_with_capturing_llm();
10877 let response = post_json(
10878 router,
10879 "/v1/chat/completions",
10880 json!({
10881 "model": "stub-model",
10882 "messages": [{"role": "user", "content": "hello"}],
10883 (field): value
10884 }),
10885 )
10886 .await;
10887 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST, "{field}");
10888 let body = response_json(response).await;
10889 assert_eq!(body["error"]["param"], field);
10890 }
10891 }
10892
10893 #[tokio::test]
10894 async fn chat_request_forbids_initial_think_close_token() {
10895 let engine = Arc::new(CapturingLlm::new());
10896 let router = AxumServer::from_llm(engine.clone()).build_router();
10897 let response = post_json(
10898 router,
10899 "/v1/chat/completions",
10900 json!({
10901 "model": "qwen3",
10902 "messages": [{"role": "user", "content": "hello"}]
10903 }),
10904 )
10905 .await;
10906 assert_eq!(response.status(), AxumStatusCode::OK);
10907
10908 let request = engine.last_request();
10909 assert_eq!(
10910 request
10911 .metadata
10912 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10913 Some(&serde_json::json!([THINK_END_TAG]))
10914 );
10915 }
10916
10917 #[tokio::test]
10918 async fn omitted_enable_thinking_preserves_model_template_default() {
10919 let template = ModelChatTemplate::new(
10920 "{% 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 %}",
10921 "test-template",
10922 );
10923 let (router, engine) = router_with_capturing_llm_and_template(template);
10924 let response = post_json(
10925 router,
10926 "/v1/chat/completions",
10927 json!({
10928 "model": "served-alias",
10929 "messages": [{"role": "user", "content": "hello"}]
10930 }),
10931 )
10932 .await;
10933 assert_eq!(response.status(), AxumStatusCode::OK);
10934
10935 let request = engine.last_request();
10936 assert!(request.prompt.ends_with("<|im_start|>assistant\n<think>\n"));
10937 assert!(!request
10938 .metadata
10939 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10940 }
10941
10942 #[tokio::test]
10943 async fn server_thinking_default_applies_but_request_override_wins() {
10944 let template = ModelChatTemplate::new(
10945 "{% 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 %}",
10946 "test-template",
10947 );
10948 let (router, engine) =
10949 router_with_capturing_llm_and_template_default(template, Some(false));
10950
10951 let response = post_json(
10952 router.clone(),
10953 "/v1/chat/completions",
10954 json!({
10955 "model": "served-alias",
10956 "messages": [{"role": "user", "content": "hello"}]
10957 }),
10958 )
10959 .await;
10960 assert_eq!(response.status(), AxumStatusCode::OK);
10961 let request = engine.last_request();
10962 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
10963 assert_eq!(
10964 request
10965 .metadata
10966 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
10967 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
10968 );
10969
10970 let response = post_json(
10971 router,
10972 "/v1/chat/completions",
10973 json!({
10974 "model": "served-alias",
10975 "messages": [{"role": "user", "content": "hello"}],
10976 "chat_template_kwargs": {"enable_thinking": true}
10977 }),
10978 )
10979 .await;
10980 assert_eq!(response.status(), AxumStatusCode::OK);
10981 let request = engine.last_request();
10982 assert_eq!(request.prompt, "<assistant><think>\n");
10983 assert!(!request
10984 .metadata
10985 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
10986 }
10987
10988 #[tokio::test]
10989 async fn chat_template_enable_thinking_true_overrides_default() {
10990 let template = ModelChatTemplate::new(
10991 "{% 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 %}",
10992 "test-template",
10993 );
10994 let (router, engine) = router_with_capturing_llm_and_template(template);
10995 let response = post_json(
10996 router,
10997 "/v1/chat/completions",
10998 json!({
10999 "model": "served-alias",
11000 "messages": [{"role": "user", "content": "hello"}],
11001 "chat_template_kwargs": {"enable_thinking": true}
11002 }),
11003 )
11004 .await;
11005 assert_eq!(response.status(), AxumStatusCode::OK);
11006
11007 let request = engine.last_request();
11008 assert_eq!(request.prompt, "<assistant><think>\n");
11009 assert!(!request
11010 .metadata
11011 .contains_key(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
11012 }
11013
11014 #[tokio::test]
11015 async fn chat_template_enable_thinking_false_is_a_hard_override() {
11016 let template = ModelChatTemplate::new(
11017 "{% 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 %}",
11018 "test-template",
11019 );
11020 let (router, engine) = router_with_capturing_llm_and_template(template);
11021 let response = post_json(
11022 router,
11023 "/v1/chat/completions",
11024 json!({
11025 "model": "served-alias",
11026 "messages": [{"role": "user", "content": "hello"}],
11027 "chat_template_kwargs": {"enable_thinking": false}
11028 }),
11029 )
11030 .await;
11031 assert_eq!(response.status(), AxumStatusCode::OK);
11032
11033 let request = engine.last_request();
11034 assert_eq!(request.prompt, "<assistant><think>\n\n</think>\n\n");
11035 assert_eq!(
11036 request
11037 .metadata
11038 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
11039 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
11040 );
11041 }
11042
11043 #[tokio::test]
11044 async fn chat_template_reasoning_effort_is_typed_and_rendered() {
11045 let template = ModelChatTemplate::new(
11046 "{% if reasoning_effort is defined %}Reasoning: {{ reasoning_effort }}{% else %}Reasoning: model-default{% endif %}",
11047 "test-template",
11048 );
11049 let (router, engine) = router_with_capturing_llm_and_template(template);
11050 let response = post_json(
11051 router.clone(),
11052 "/v1/chat/completions",
11053 json!({
11054 "model": "served-alias",
11055 "messages": [{"role": "user", "content": "hello"}],
11056 "chat_template_kwargs": {"reasoning_effort": "low"}
11057 }),
11058 )
11059 .await;
11060 assert_eq!(response.status(), AxumStatusCode::OK);
11061 assert_eq!(engine.last_request().prompt, "Reasoning: low");
11062
11063 let response = post_json(
11064 router.clone(),
11065 "/v1/chat/completions",
11066 json!({
11067 "model": "served-alias",
11068 "messages": [{"role": "user", "content": "hello"}],
11069 "chat_template_kwargs": {"reasoning_effort": "xhigh"}
11070 }),
11071 )
11072 .await;
11073 assert_eq!(response.status(), AxumStatusCode::OK);
11074 assert_eq!(engine.last_request().prompt, "Reasoning: xhigh");
11075
11076 for invalid in [json!("extreme"), json!(1)] {
11077 let response = post_json(
11078 router.clone(),
11079 "/v1/chat/completions",
11080 json!({
11081 "model": "served-alias",
11082 "messages": [{"role": "user", "content": "hello"}],
11083 "chat_template_kwargs": {"reasoning_effort": invalid}
11084 }),
11085 )
11086 .await;
11087 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11088 let body = response_json(response).await;
11089 assert_eq!(body["error"]["type"], "invalid_request_error");
11090 assert!(body["error"]["message"]
11091 .as_str()
11092 .unwrap_or_default()
11093 .contains("reasoning_effort"));
11094 }
11095 }
11096
11097 #[tokio::test]
11098 async fn chat_template_enable_thinking_rejects_non_bool() {
11099 let template = ModelChatTemplate::new(
11100 "{% if add_generation_prompt %}<assistant>{% endif %}",
11101 "test-template",
11102 );
11103 let (router, _) = router_with_capturing_llm_and_template(template);
11104 let response = post_json(
11105 router,
11106 "/v1/chat/completions",
11107 json!({
11108 "model": "served-alias",
11109 "messages": [{"role": "user", "content": "hello"}],
11110 "chat_template_kwargs": {"enable_thinking": "false"}
11111 }),
11112 )
11113 .await;
11114 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11115 let body = response_json(response).await;
11116 assert_eq!(body["error"]["type"], "invalid_request_error");
11117 assert!(body["error"]["message"]
11118 .as_str()
11119 .unwrap_or_default()
11120 .contains("chat_template_kwargs.enable_thinking must be a boolean"));
11121 }
11122
11123 #[tokio::test]
11124 async fn stop_string_strips_chat_and_completion_suffixes() {
11125 let chat = post_json(
11126 router_with_stub("hello<END>"),
11127 "/v1/chat/completions",
11128 json!({
11129 "model": "stub-model",
11130 "messages": [{"role": "user", "content": "hello"}],
11131 "stop": "<END>"
11132 }),
11133 )
11134 .await;
11135 assert_eq!(chat.status(), AxumStatusCode::OK);
11136 let chat_body = response_json(chat).await;
11137 assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
11138
11139 let completion = post_json(
11140 router_with_stub("done<END>"),
11141 "/v1/completions",
11142 json!({
11143 "model": "stub-model",
11144 "prompt": "complete",
11145 "stop": "<END>"
11146 }),
11147 )
11148 .await;
11149 assert_eq!(completion.status(), AxumStatusCode::OK);
11150 let completion_body = response_json(completion).await;
11151 assert_eq!(completion_body["choices"][0]["text"], "done");
11152 }
11153
11154 #[test]
11155 fn started_in_think_parse_streams_reasoning_before_end_tag() {
11156 let parsed = parse_reasoning_response_started_in_think("Okay, the user wants");
11160 assert_eq!(parsed.reasoning.as_deref(), Some("Okay, the user wants"));
11161 assert_eq!(parsed.content, "");
11162
11163 let parsed = parse_reasoning_response_started_in_think("thinking...</think>\nanswer");
11164 assert_eq!(parsed.reasoning.as_deref(), Some("thinking..."));
11165 assert_eq!(parsed.content, "answer");
11166
11167 let parsed = parse_reasoning_response_started_in_think("<think>\nx\n</think>\n\nanswer");
11169 assert_eq!(parsed.reasoning.as_deref(), Some("\nx\n"));
11170 assert_eq!(parsed.content, "answer");
11171 }
11172
11173 #[tokio::test]
11174 async fn chat_response_splits_reasoning_from_content() {
11175 let response = post_json(
11176 router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
11177 "/v1/chat/completions",
11178 json!({
11179 "model": "stub-model",
11180 "messages": [{"role": "user", "content": "hello"}]
11181 }),
11182 )
11183 .await;
11184 assert_eq!(response.status(), AxumStatusCode::OK);
11185
11186 let body = response_json(response).await;
11187 let message = &body["choices"][0]["message"];
11188 assert_eq!(message["content"], "final answer");
11189 assert_eq!(message["reasoning"], "\nreasoning\n");
11190 assert!(message.get("reasoning_content").is_none());
11191 }
11192
11193 #[tokio::test]
11194 async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
11195 let response = post_json(
11196 router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
11197 "/v1/chat/completions",
11198 json!({
11199 "model": "stub-model",
11200 "messages": [{"role": "user", "content": "think then answer"}],
11201 "stream": true
11202 }),
11203 )
11204 .await;
11205 assert_eq!(response.status(), AxumStatusCode::OK);
11206 let body = response_text(response).await;
11207 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
11208 assert!(
11209 body.contains(r#""reasoning":"\nreason"#),
11210 "stream should emit reasoning delta after full think prefix: {body}"
11211 );
11212 assert!(!body.contains("\"reasoning_content\":"));
11213 assert!(
11214 body.contains(r#""content":"final""#),
11215 "stream should emit visible content after think close: {body}"
11216 );
11217 assert!(
11218 !body.contains(r#""content":"<"#),
11219 "partial think prefix must not leak as content: {body}"
11220 );
11221 }
11222
11223 #[tokio::test]
11224 async fn route_rejects_unsupported_tool_and_function_selection() {
11225 for (extra, param) in [
11226 (
11227 json!({
11228 "tools": [{
11229 "type": "function",
11230 "function": {"name": "weather", "parameters": {"type": "object"}}
11231 }],
11232 "tool_choice": {
11233 "type": "function",
11234 "function": {"name": "calendar"}
11235 }
11236 }),
11237 "tool_choice",
11238 ),
11239 (
11240 json!({
11241 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
11242 "function_call": {"name": "calendar"}
11243 }),
11244 "function_call",
11245 ),
11246 ] {
11247 let mut body = json!({
11248 "model": "stub-model",
11249 "messages": [{"role": "user", "content": "hello"}]
11250 });
11251 body.as_object_mut()
11252 .expect("object")
11253 .extend(extra.as_object().expect("extra object").clone());
11254 let response =
11255 post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
11256 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11257 let body = response_json(response).await;
11258 assert_eq!(body["error"]["type"], "invalid_request_error");
11259 assert_eq!(body["error"]["param"], param);
11260 }
11261 }
11262
11263 #[tokio::test]
11264 async fn route_rejects_non_function_tools_with_openai_error_param() {
11265 let response = post_json(
11266 router_with_stub("unused"),
11267 "/v1/chat/completions",
11268 json!({
11269 "model": "stub-model",
11270 "messages": [{"role": "user", "content": "hello"}],
11271 "tools": [{
11272 "type": "retrieval",
11273 "function": {"name": "search", "parameters": {"type": "object"}}
11274 }]
11275 }),
11276 )
11277 .await;
11278 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11279 let body = response_json(response).await;
11280 assert_eq!(body["error"]["type"], "invalid_request_error");
11281 assert_eq!(body["error"]["param"], "tools");
11282 }
11283
11284 #[tokio::test]
11285 async fn route_rejects_tool_choice_required_without_tools() {
11286 let response = post_json(
11287 router_with_stub("unused"),
11288 "/v1/chat/completions",
11289 json!({
11290 "model": "stub-model",
11291 "messages": [{"role": "user", "content": "hello"}],
11292 "tool_choice": "required"
11293 }),
11294 )
11295 .await;
11296 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11297 let body = response_json(response).await;
11298 assert_eq!(body["error"]["type"], "invalid_request_error");
11299 assert_eq!(body["error"]["param"], "tool_choice");
11300 }
11301
11302 #[tokio::test]
11303 async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
11304 let response = post_json(
11305 router_with_stub("unused"),
11306 "/v1/chat/completions",
11307 json!({
11308 "model": "stub-model",
11309 "messages": [{"role": "user", "content": "hello"}],
11310 "response_format": {"type": "xml"}
11311 }),
11312 )
11313 .await;
11314 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11315 let body = response_json(response).await;
11316 assert_eq!(body["error"]["type"], "invalid_request_error");
11317 assert_eq!(body["error"]["param"], "response_format.type");
11318 }
11319
11320 #[tokio::test]
11321 async fn route_chat_engine_unavailable_maps_to_503() {
11322 let response = post_json(
11323 router_without_llm(),
11324 "/v1/chat/completions",
11325 json!({
11326 "model": "stub-model",
11327 "messages": [{"role": "user", "content": "hello"}]
11328 }),
11329 )
11330 .await;
11331 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11332 let body = response_json(response).await;
11333 assert_eq!(body["error"]["type"], "service_unavailable_error");
11334 assert_eq!(body["error"]["param"], Value::Null);
11335 }
11336
11337 #[tokio::test]
11338 async fn context_capacity_rejection_has_a_structured_code_on_openai_routes() {
11339 for stream in [false, true] {
11340 for (path, mut input) in [
11341 (
11342 "/v1/chat/completions",
11343 json!({"messages":[{"role":"user","content":"hello"}],"max_tokens":100}),
11344 ),
11345 (
11346 "/v1/completions",
11347 json!({"prompt":"hello","max_tokens":100}),
11348 ),
11349 (
11350 "/v1/responses",
11351 json!({"input":"hello","max_output_tokens":100}),
11352 ),
11353 ] {
11354 input["model"] = json!("failing-model");
11355 input["stream"] = json!(stream);
11356 let router = AxumServer::from_llm(Arc::new(FailingLlm::context_length_exceeded()))
11357 .build_router();
11358 let response = post_json(router, path, input).await;
11359 assert_eq!(
11360 response.status(),
11361 AxumStatusCode::BAD_REQUEST,
11362 "{path}, stream={stream}"
11363 );
11364 let body = response_json(response).await;
11365 assert_eq!(body["error"]["code"], "context_length_exceeded", "{body}");
11366 assert_eq!(body["error"]["type"], "invalid_request_error");
11367 assert!(body["error"]["message"]
11368 .as_str()
11369 .unwrap()
11370 .contains("500 input tokens + 100 output tokens"));
11371 }
11372 }
11373 let ordinary =
11374 server_error_from_ferrum_error(Error::request_validation("invalid parameter"))
11375 .into_response();
11376 assert_eq!(response_json(ordinary).await["error"]["code"], Value::Null);
11377 }
11378
11379 #[tokio::test]
11380 async fn route_chat_generation_failure_maps_to_500() {
11381 let response = post_json(
11382 router_with_failing_llm(),
11383 "/v1/chat/completions",
11384 json!({
11385 "model": "failing-model",
11386 "messages": [{"role": "user", "content": "hello"}]
11387 }),
11388 )
11389 .await;
11390 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11391 let body = response_json(response).await;
11392 assert_eq!(body["error"]["type"], "internal_server_error");
11393 assert!(body["error"]["message"]
11394 .as_str()
11395 .unwrap()
11396 .contains("stub generation failed"));
11397 }
11398
11399 #[tokio::test]
11400 async fn route_chat_generation_failure_writes_replay_diagnostics() {
11401 let root = unique_request_dump_dir("chat-sync-failure");
11402 let profile = unique_profile_jsonl("chat-sync-failure");
11403 let response = post_json(
11404 router_with_failing_llm_request_dump_and_profile(root.clone(), profile.clone()),
11405 "/v1/chat/completions",
11406 json!({
11407 "model": "failing-model",
11408 "messages": [{"role": "user", "content": "hello"}]
11409 }),
11410 )
11411 .await;
11412 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11413 assert_chat_failure_replay_bundle(
11414 &root,
11415 "chat_completions_sync",
11416 "internal",
11417 "stub generation failed",
11418 );
11419 let event = read_profile_events(&profile)
11420 .into_iter()
11421 .find(|event| event["phase"] == "chat_completions_sync")
11422 .expect("sync failure profile event");
11423 assert_eq!(event["event_kind"], "timed_span");
11424 assert_eq!(event["status"], "failure");
11425 assert!(event["duration_us"].as_u64().is_some());
11426 assert_eq!(event["attributes"]["terminal_failure_event"], true);
11427 assert_eq!(event["error"]["kind"], "internal");
11428 let _ = fs::remove_dir_all(root);
11429 let _ = fs::remove_file(profile);
11430 }
11431
11432 #[tokio::test]
11433 async fn route_chat_resource_failure_writes_resource_replay_diagnostics() {
11434 let root = unique_request_dump_dir("chat-resource-failure");
11435 let response = post_json(
11436 router_with_resource_exhausted_llm_and_request_dump_dir(root.clone()),
11437 "/v1/chat/completions",
11438 json!({
11439 "model": "failing-model",
11440 "messages": [{"role": "user", "content": "hello"}]
11441 }),
11442 )
11443 .await;
11444 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
11445 let bundle = only_replay_bundle(&root);
11446 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11447 assert_eq!(bad_scan["failure_kind"], "oom_admission");
11448 let diagnostics = read_json_file(bundle.join("failure_diagnostics.json"));
11449 assert_eq!(diagnostics["failure_kind"], "oom_admission");
11450 assert_eq!(
11451 diagnostics["first_failure_event"]["error_kind"],
11452 "resource_exhausted"
11453 );
11454 assert_eq!(
11455 diagnostics["capacity"]["resource_kind"],
11456 "admission_capacity"
11457 );
11458 assert!(diagnostics["capacity"]["reason"]
11459 .as_str()
11460 .expect("capacity reason")
11461 .contains("admission capacity exhausted"));
11462 assert_eq!(
11463 diagnostics["nearest_resource_event"]["resource_kind"],
11464 "admission_capacity"
11465 );
11466 assert!(diagnostics["nearest_memory_snapshot"]["current_bytes"].is_number());
11467 assert!(diagnostics["nearest_memory_snapshot"]["high_water_bytes"].is_number());
11468 let _ = fs::remove_dir_all(root);
11469 }
11470
11471 #[tokio::test]
11472 async fn route_chat_sync_success_updates_replay_output_tokens() {
11473 let root = unique_request_dump_dir("chat-sync-success-output");
11474 let response = post_json(
11475 router_with_stub_and_request_dump_dir("OK", root.clone()),
11476 "/v1/chat/completions",
11477 json!({
11478 "model": "stub-model",
11479 "messages": [{"role": "user", "content": "hello"}]
11480 }),
11481 )
11482 .await;
11483 assert_eq!(response.status(), AxumStatusCode::OK);
11484 let body = response_json(response).await;
11485 assert_eq!(body["choices"][0]["message"]["content"], "OK");
11486 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11487 let _ = fs::remove_dir_all(root);
11488 }
11489
11490 #[tokio::test]
11491 async fn route_chat_sync_success_writes_product_profile_event() {
11492 let root = unique_request_dump_dir("chat-sync-profile");
11493 let profile = unique_profile_jsonl("chat-sync-profile");
11494 let response = post_json(
11495 router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11496 "/v1/chat/completions",
11497 json!({
11498 "model": "stub-model",
11499 "messages": [{"role": "user", "content": "hello"}]
11500 }),
11501 )
11502 .await;
11503 assert_eq!(response.status(), AxumStatusCode::OK);
11504 let response_body = response_json(response).await;
11505
11506 let events = read_profile_events(&profile);
11507 assert_eq!(events.len(), 2, "events: {events:#?}");
11508 let event = events
11509 .iter()
11510 .find(|event| event["phase"] == "chat_completions_sync_complete")
11511 .expect("sync completion profile event");
11512 assert!(response_body["id"]
11513 .as_str()
11514 .is_some_and(|id| !id.is_empty()));
11515 assert_eq!(response_body["id"], event["request_id"]);
11516 assert_eq!(response_body["id"], event["correlation_id"]);
11517 assert_eq!(
11518 event["schema_version"],
11519 OBSERVABILITY_PROFILE_SCHEMA_VERSION
11520 );
11521 assert_eq!(event["entrypoint"], "serve");
11522 assert_eq!(event["event_kind"], "timed_span");
11523 assert_eq!(event["status"], "ok");
11524 assert_eq!(event["phase"], "chat_completions_sync_complete");
11525 assert_eq!(event["attributes"]["actual_model_smoke"], true);
11526 assert_eq!(event["attributes"]["profile_detail"], "latency");
11527 assert_eq!(event["attributes"]["diagnostic_only"], false);
11528 assert_eq!(event["attributes"]["stream"], false);
11529 assert_eq!(event["attributes"]["output_token_count"], 2);
11530 assert_eq!(event["attributes"]["prompt_token_count"], 7);
11531 assert_eq!(event["attributes"]["completion_token_count"], 2);
11532 assert_eq!(event["attributes"]["total_token_count"], 9);
11533 assert_eq!(event["attributes"]["token_count_source"], "usage");
11534 assert_eq!(event["attributes"]["finish_reason"], "stop");
11535 assert_eq!(
11536 event["attributes"]["engine_token_clock_source"],
11537 "rust_std_instant"
11538 );
11539 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11540 assert_eq!(event["attributes"]["itl_interval_count"], 1);
11541 assert_eq!(event["attributes"]["ttft_us"], 1_000);
11542 assert_eq!(event["attributes"]["itl_us_avg"], 1_000);
11543 assert!(event["attributes"]["http_first_sse_enqueue_us"].is_null());
11544 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11545 assert!(
11546 event["attributes"]["e2e_duration_us"]
11547 .as_u64()
11548 .unwrap_or_default()
11549 > 0
11550 );
11551 assert_eq!(
11552 event["replay"]["bundle_dir"].as_str(),
11553 Some(root.to_string_lossy().as_ref())
11554 );
11555 assert!(event["replay"]["command"]
11556 .as_str()
11557 .unwrap_or_default()
11558 .contains("replay_body.json"));
11559 let memory_event = events
11560 .iter()
11561 .find(|event| event["phase"] == "actual_serve_first_request_done")
11562 .expect("first request memory profile event");
11563 assert_eq!(memory_event["event_kind"], "memory");
11564 assert_eq!(
11565 memory_event["attributes"]["memory_stage"],
11566 "first_request_done"
11567 );
11568 assert_eq!(
11569 memory_event["attributes"]["memory_measurement"],
11570 "process_rss"
11571 );
11572 assert!(memory_event["memory"]["current_bytes"]
11573 .as_u64()
11574 .is_some_and(|bytes| bytes > 0));
11575 let _ = fs::remove_dir_all(root);
11576 let _ = fs::remove_file(profile);
11577 }
11578
11579 #[tokio::test]
11580 async fn route_chat_profile_events_preserve_benchmark_correlation() {
11581 let root = unique_request_dump_dir("chat-benchmark-correlation");
11582 let profile = unique_profile_jsonl("chat-benchmark-correlation");
11583 let correlation = BenchmarkRequestCorrelation::new(
11584 "bench-123".to_string(),
11585 "cell-1-closed-c8".to_string(),
11586 2,
11587 ferrum_bench_core::BenchmarkPhase::Measured,
11588 17,
11589 )
11590 .unwrap();
11591 let response = post_json_with_benchmark_correlation(
11592 router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone()),
11593 "/v1/chat/completions",
11594 json!({
11595 "model": "stub-model",
11596 "messages": [{"role": "user", "content": "hello"}]
11597 }),
11598 &correlation,
11599 )
11600 .await;
11601 assert_eq!(response.status(), AxumStatusCode::OK);
11602 let _ = response_json(response).await;
11603
11604 let events = read_profile_events(&profile);
11605 assert_eq!(events.len(), 2, "events: {events:#?}");
11606 for event in events {
11607 assert_eq!(event["attributes"]["benchmark_run_id"], "bench-123");
11608 assert_eq!(event["attributes"]["cell_id"], "cell-1-closed-c8");
11609 assert_eq!(event["attributes"]["repeat_index"], 2);
11610 assert_eq!(event["attributes"]["phase"], "measured");
11611 assert_eq!(event["attributes"]["request_index"], 17);
11612 }
11613 let _ = fs::remove_dir_all(root);
11614 let _ = fs::remove_file(profile);
11615 }
11616
11617 #[tokio::test]
11618 async fn route_chat_rejects_partial_benchmark_correlation_headers() {
11619 let response = router_with_stub("OK")
11620 .oneshot(
11621 Request::builder()
11622 .method("POST")
11623 .uri("/v1/chat/completions")
11624 .header(header::CONTENT_TYPE, "application/json")
11625 .header(BENCHMARK_RUN_ID_HEADER, "bench-123")
11626 .body(Body::from(
11627 json!({
11628 "model": "stub-model",
11629 "messages": [{"role": "user", "content": "hello"}]
11630 })
11631 .to_string(),
11632 ))
11633 .expect("request"),
11634 )
11635 .await
11636 .expect("route response");
11637 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
11638 }
11639
11640 #[tokio::test]
11641 async fn route_chat_sync_profile_jsonl_is_parseable_under_concurrent_requests() {
11642 let root = unique_request_dump_dir("chat-sync-profile-concurrent");
11643 let profile = unique_profile_jsonl("chat-sync-profile-concurrent");
11644 let app = router_with_stub_request_dump_and_profile("OK", root.clone(), profile.clone());
11645
11646 let mut handles = Vec::new();
11647 for request_index in 0..8 {
11648 let app = app.clone();
11649 handles.push(tokio::spawn(async move {
11650 let response = post_json(
11651 app,
11652 "/v1/chat/completions",
11653 json!({
11654 "model": "stub-model",
11655 "messages": [{"role": "user", "content": format!("hello {request_index}")}]
11656 }),
11657 )
11658 .await;
11659 assert_eq!(response.status(), AxumStatusCode::OK);
11660 let body = response_json(response).await;
11661 assert_eq!(body["choices"][0]["message"]["content"], "OK");
11662 }));
11663 }
11664
11665 for handle in handles {
11666 handle.await.expect("concurrent request task");
11667 }
11668
11669 let raw = fs::read_to_string(&profile).expect("profile jsonl");
11670 let mut completion_events = 0usize;
11671 for (line_index, line) in raw
11672 .lines()
11673 .filter(|line| !line.trim().is_empty())
11674 .enumerate()
11675 {
11676 let event: Value = serde_json::from_str(line).unwrap_or_else(|err| {
11677 panic!(
11678 "profile line {} invalid JSON: {err}: {line}",
11679 line_index + 1
11680 )
11681 });
11682 if event["phase"] == "chat_completions_sync_complete" {
11683 completion_events += 1;
11684 }
11685 }
11686 assert_eq!(completion_events, 8);
11687 let _ = fs::remove_dir_all(root);
11688 let _ = fs::remove_file(profile);
11689 }
11690
11691 #[tokio::test]
11692 async fn route_chat_stream_success_updates_replay_output_tokens() {
11693 let root = unique_request_dump_dir("chat-stream-success-output");
11694 let response = post_json(
11695 router_with_stub_stream_chunks_and_request_dump_dir(&["O", "K"], root.clone()),
11696 "/v1/chat/completions",
11697 json!({
11698 "model": "stub-model",
11699 "messages": [{"role": "user", "content": "hello"}],
11700 "stream": true
11701 }),
11702 )
11703 .await;
11704 assert_eq!(response.status(), AxumStatusCode::OK);
11705 let body = response_text(response).await;
11706 assert!(body.contains("data: [DONE]"), "body: {body}");
11707 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "OK");
11708 let _ = fs::remove_dir_all(root);
11709 }
11710
11711 #[tokio::test]
11712 async fn route_chat_stream_success_writes_product_profile_event() {
11713 let root = unique_request_dump_dir("chat-stream-profile");
11714 let profile = unique_profile_jsonl("chat-stream-profile");
11715 let response = post_json(
11716 router_with_stub_stream_request_dump_and_profile(
11717 &["O", "K"],
11718 root.clone(),
11719 profile.clone(),
11720 ),
11721 "/v1/chat/completions",
11722 json!({
11723 "model": "stub-model",
11724 "messages": [{"role": "user", "content": "hello"}],
11725 "stream": true
11726 }),
11727 )
11728 .await;
11729 assert_eq!(response.status(), AxumStatusCode::OK);
11730 let body = response_text(response).await;
11731 assert!(body.contains("data: [DONE]"), "body: {body}");
11732
11733 let events = read_profile_events(&profile);
11734 assert_eq!(events.len(), 2, "events: {events:#?}");
11735 let event = events
11736 .iter()
11737 .find(|event| event["phase"] == "chat_completions_stream_complete")
11738 .expect("stream completion profile event");
11739 let chunks = responses_sse_json_events(&body);
11740 assert!(!chunks.is_empty());
11741 for chunk in chunks {
11742 assert!(chunk["id"].as_str().is_some_and(|id| !id.is_empty()));
11743 assert_eq!(chunk["id"], event["request_id"]);
11744 assert_eq!(chunk["id"], event["correlation_id"]);
11745 }
11746 assert_eq!(
11747 event["schema_version"],
11748 OBSERVABILITY_PROFILE_SCHEMA_VERSION
11749 );
11750 assert_eq!(event["entrypoint"], "serve");
11751 assert_eq!(event["event_kind"], "timed_span");
11752 assert_eq!(event["status"], "ok");
11753 assert_eq!(event["phase"], "chat_completions_stream_complete");
11754 assert_eq!(event["attributes"]["actual_model_smoke"], true);
11755 assert_eq!(event["attributes"]["profile_detail"], "latency");
11756 assert_eq!(event["attributes"]["diagnostic_only"], false);
11757 assert_eq!(event["attributes"]["stream"], true);
11758 assert_eq!(event["attributes"]["output_token_count"], 2);
11759 assert_eq!(event["attributes"]["prompt_token_count"], 5);
11760 assert_eq!(event["attributes"]["completion_token_count"], 2);
11761 assert_eq!(event["attributes"]["total_token_count"], 7);
11762 assert_eq!(event["attributes"]["token_count_source"], "usage");
11763 assert_eq!(event["attributes"]["finish_reason"], "stop");
11764 assert!(event["duration_us"].as_u64().unwrap_or_default() > 0);
11765 assert!(
11766 event["attributes"]["e2e_duration_us"]
11767 .as_u64()
11768 .unwrap_or_default()
11769 > 0
11770 );
11771 assert!(event["attributes"]["ttft_us"].as_u64().is_some());
11772 assert!(event["attributes"]["itl_us_avg"].as_u64().is_some());
11773 assert_eq!(
11774 event["attributes"]["engine_token_commit_nanos_since_request_start"],
11775 json!([1_000_000, 2_000_000])
11776 );
11777 assert_eq!(event["attributes"]["itl_interval_count"], 1);
11778 assert_eq!(event["attributes"]["itl_source"], "engine_token_commit");
11779 assert!(event["attributes"]["engine_stream_first_chunk_received_us"]
11780 .as_u64()
11781 .is_some());
11782 assert!(event["attributes"]["http_first_sse_enqueue_us"]
11783 .as_u64()
11784 .is_some());
11785 assert!(event["attributes"]["http_stream_flush_unavailable_reason"]
11786 .as_str()
11787 .is_some());
11788 assert_eq!(
11789 event["replay"]["bundle_dir"].as_str(),
11790 Some(root.to_string_lossy().as_ref())
11791 );
11792 let memory_event = events
11793 .iter()
11794 .find(|event| event["phase"] == "actual_serve_first_request_done")
11795 .expect("first request memory profile event");
11796 assert_eq!(memory_event["event_kind"], "memory");
11797 assert_eq!(
11798 memory_event["attributes"]["memory_stage"],
11799 "first_request_done"
11800 );
11801 assert_eq!(
11802 memory_event["attributes"]["memory_measurement"],
11803 "process_rss"
11804 );
11805 assert!(memory_event["memory"]["current_bytes"]
11806 .as_u64()
11807 .is_some_and(|bytes| bytes > 0));
11808 let _ = fs::remove_dir_all(root);
11809 let _ = fs::remove_file(profile);
11810 }
11811
11812 #[tokio::test]
11813 async fn route_chat_stream_profile_retains_non_visible_terminal_token() {
11814 let root = unique_request_dump_dir("chat-stream-profile-terminal-token");
11815 let profile = unique_profile_jsonl("chat-stream-profile-terminal-token");
11816 let llm = StubLlm {
11817 stream_usage: Some(TokenUsage::new(5, 2)),
11818 ..StubLlm::with_stream_chunks(&["Paris"])
11819 };
11820 let app = AxumServer::from_state(
11821 AppState::default()
11822 .with_llm(Arc::new(llm))
11823 .with_request_dump_dir(Some(root.clone()))
11824 .with_profile_detail(ferrum_types::ObservabilityProfileDetail::Latency)
11825 .with_profile_jsonl(Some(profile.clone())),
11826 )
11827 .build_router();
11828
11829 let response = post_json(
11830 app,
11831 "/v1/chat/completions",
11832 json!({
11833 "model": "stub-model",
11834 "messages": [{"role": "user", "content": "hello"}],
11835 "stream": true,
11836 "stream_options": {"include_usage": true}
11837 }),
11838 )
11839 .await;
11840 assert_eq!(response.status(), AxumStatusCode::OK);
11841 let body = response_text(response).await;
11842 assert!(body.contains("\"completion_tokens\":2"), "body: {body}");
11843 assert!(body.contains("data: [DONE]"), "body: {body}");
11844
11845 let events = read_profile_events(&profile);
11846 let event = events
11847 .iter()
11848 .find(|event| event["phase"] == "chat_completions_stream_complete")
11849 .expect("stream completion profile event");
11850 assert_eq!(event["attributes"]["output_token_count"], 2);
11851 assert_eq!(event["attributes"]["completion_token_count"], 2);
11852 assert_eq!(event["attributes"]["engine_token_commit_count"], 2);
11853 assert_chat_success_replay_bundle(&root, &[11, 12], "stop", "Paris");
11854
11855 let _ = fs::remove_dir_all(root);
11856 let _ = fs::remove_file(profile);
11857 }
11858
11859 #[tokio::test]
11860 async fn route_chat_sync_bad_output_updates_replay_classifier() {
11861 let root = unique_request_dump_dir("chat-sync-bad-output");
11862 let response = post_json(
11863 router_with_stub_and_request_dump_dir("<unk>", root.clone()),
11864 "/v1/chat/completions",
11865 json!({
11866 "model": "stub-model",
11867 "messages": [{"role": "user", "content": "hello"}]
11868 }),
11869 )
11870 .await;
11871 assert_eq!(response.status(), AxumStatusCode::OK);
11872 let _ = response_json(response).await;
11873 let bundle = only_replay_bundle(&root);
11874 let bad_scan = read_json_file(bundle.join("bad_output_scan.json"));
11875 assert_eq!(bad_scan["bad_output"], true);
11876 assert_eq!(bad_scan["reasons"], json!(["reserved_token"]));
11877 assert_eq!(bad_scan["first_bad_text_span"]["reason"], "reserved_token");
11878 let _ = fs::remove_dir_all(root);
11879 }
11880
11881 #[tokio::test]
11882 async fn route_chat_stream_generation_failure_emits_openai_error_event() {
11883 let response = post_json(
11884 router_with_failing_llm(),
11885 "/v1/chat/completions",
11886 json!({
11887 "model": "failing-model",
11888 "messages": [{"role": "user", "content": "hello"}],
11889 "stream": true
11890 }),
11891 )
11892 .await;
11893 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11894 let body = response_json(response).await;
11895 assert_eq!(body["error"]["type"], "internal_server_error");
11896 assert!(body["error"]["message"]
11897 .as_str()
11898 .unwrap_or_default()
11899 .contains("stub stream failed"));
11900 }
11901
11902 #[tokio::test]
11903 async fn route_chat_stream_generation_failure_writes_replay_diagnostics() {
11904 let root = unique_request_dump_dir("chat-stream-start-failure");
11905 let response = post_json(
11906 router_with_failing_llm_and_request_dump_dir(root.clone()),
11907 "/v1/chat/completions",
11908 json!({
11909 "model": "failing-model",
11910 "messages": [{"role": "user", "content": "hello"}],
11911 "stream": true
11912 }),
11913 )
11914 .await;
11915 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
11916 let body = response_json(response).await;
11917 assert_eq!(body["error"]["type"], "internal_server_error");
11918 assert!(body["error"]["message"]
11919 .as_str()
11920 .unwrap_or_default()
11921 .contains("stub stream failed"));
11922 assert_chat_failure_replay_bundle(
11923 &root,
11924 "chat_completions_stream_start",
11925 "internal",
11926 "stub stream failed",
11927 );
11928 let _ = fs::remove_dir_all(root);
11929 }
11930
11931 #[tokio::test]
11932 async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
11933 let response = post_json(
11934 router_with_stream_chunk_failing_llm(),
11935 "/v1/chat/completions",
11936 json!({
11937 "model": "failing-model",
11938 "messages": [{"role": "user", "content": "hello"}],
11939 "stream": true
11940 }),
11941 )
11942 .await;
11943 assert_eq!(response.status(), AxumStatusCode::OK);
11944 let body = response_text(response).await;
11945 assert_openai_stream_error(&body, "stub stream chunk failed");
11946 }
11947
11948 #[tokio::test]
11949 async fn route_chat_stream_chunk_failure_writes_replay_diagnostics() {
11950 let root = unique_request_dump_dir("chat-stream-chunk-failure");
11951 let response = post_json(
11952 router_with_stream_chunk_failing_llm_and_request_dump_dir(root.clone()),
11953 "/v1/chat/completions",
11954 json!({
11955 "model": "failing-model",
11956 "messages": [{"role": "user", "content": "hello"}],
11957 "stream": true
11958 }),
11959 )
11960 .await;
11961 assert_eq!(response.status(), AxumStatusCode::OK);
11962 let body = response_text(response).await;
11963 assert_openai_stream_error(&body, "stub stream chunk failed");
11964 assert_chat_failure_replay_bundle(
11965 &root,
11966 "chat_completions_stream_next",
11967 "internal",
11968 "stub stream chunk failed",
11969 );
11970 let _ = fs::remove_dir_all(root);
11971 }
11972
11973 #[tokio::test]
11974 async fn route_completions_engine_unavailable_maps_to_503() {
11975 let response = post_json(
11976 router_without_llm(),
11977 "/v1/completions",
11978 json!({
11979 "model": "stub-model",
11980 "prompt": "complete me"
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_engine_unavailable_maps_to_503() {
11992 let response = post_json(
11993 router_without_llm(),
11994 "/v1/embeddings",
11995 json!({
11996 "model": "embed-model",
11997 "input": "hello"
11998 }),
11999 )
12000 .await;
12001 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
12002 let body = response_json(response).await;
12003 assert_eq!(body["error"]["type"], "service_unavailable_error");
12004 assert_eq!(body["error"]["param"], Value::Null);
12005 }
12006
12007 #[tokio::test]
12008 async fn route_embeddings_contract_uses_stub_engine() {
12009 let response = post_json(
12010 router_with_stub_embed(),
12011 "/v1/embeddings",
12012 json!({
12013 "model": "stub-embed",
12014 "input": ["hi", "world"],
12015 "encoding_format": "float"
12016 }),
12017 )
12018 .await;
12019 assert_eq!(response.status(), AxumStatusCode::OK);
12020 let body = response_json(response).await;
12021 assert_eq!(body["object"], "list");
12022 assert_eq!(body["model"], "stub-embed");
12023 assert_eq!(body["usage"]["prompt_tokens"], 7);
12024 assert_eq!(body["usage"]["total_tokens"], 7);
12025
12026 let data = body["data"].as_array().expect("embedding data");
12027 assert_eq!(data.len(), 2, "body: {body}");
12028 assert_eq!(data[0]["object"], "embedding");
12029 assert_eq!(data[0]["index"], 0);
12030 assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
12031 assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
12032 assert_eq!(data[1]["index"], 1);
12033 assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
12034 }
12035
12036 #[tokio::test]
12037 async fn route_embeddings_public_alias_succeeds_and_unknown_alias_is_rejected() {
12038 let registry = ServedModelRegistry::try_new(
12039 "stub-embed",
12040 ServedModelKind::Embedding,
12041 vec!["public-embed".to_string()],
12042 vec![],
12043 )
12044 .unwrap();
12045 let server =
12046 AxumServer::from_embed(Arc::new(StubEmbed::new())).with_served_model_registry(registry);
12047 let accepted = post_json(
12048 server.build_router(),
12049 "/v1/embeddings",
12050 json!({"model": "public-embed", "input": "hello"}),
12051 )
12052 .await;
12053 assert_eq!(accepted.status(), AxumStatusCode::OK);
12054 assert_eq!(response_json(accepted).await["model"], "public-embed");
12055
12056 let rejected = post_json(
12057 server.build_router(),
12058 "/v1/embeddings",
12059 json!({"model": "stub-embed", "input": "hello"}),
12060 )
12061 .await;
12062 assert_eq!(rejected.status(), AxumStatusCode::BAD_REQUEST);
12063 let body = response_json(rejected).await;
12064 assert_eq!(body["error"]["type"], "invalid_request_error");
12065 assert_eq!(body["error"]["param"], "model");
12066 }
12067
12068 #[tokio::test]
12069 async fn route_embeddings_rejects_unsupported_encoding_format() {
12070 let response = post_json(
12071 router_with_stub_embed(),
12072 "/v1/embeddings",
12073 json!({
12074 "model": "stub-embed",
12075 "input": "hi",
12076 "encoding_format": "base64"
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"], "encoding_format");
12084 }
12085
12086 #[tokio::test]
12087 async fn route_embeddings_rejects_empty_input_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_rejects_empty_item_with_field_param() {
12105 let response = post_json(
12106 router_with_stub_embed(),
12107 "/v1/embeddings",
12108 json!({
12109 "model": "stub-embed",
12110 "input": [{}]
12111 }),
12112 )
12113 .await;
12114 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12115 let body = response_json(response).await;
12116 assert_eq!(body["error"]["type"], "invalid_request_error");
12117 assert_eq!(body["error"]["param"], "input");
12118 }
12119
12120 #[tokio::test]
12121 async fn route_embeddings_invalid_json_maps_to_openai_error() {
12122 let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
12123 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12124 let body = response_json(response).await;
12125 assert_eq!(body["error"]["type"], "invalid_request_error");
12126 assert_eq!(body["error"]["param"], Value::Null);
12127 assert!(body["error"]["message"]
12128 .as_str()
12129 .unwrap()
12130 .contains("invalid embeddings request"));
12131 }
12132
12133 #[tokio::test]
12134 async fn route_transcriptions_engine_unavailable_maps_to_503() {
12135 let boundary = "ferrum-test-boundary";
12136 let body = concat!(
12137 "--ferrum-test-boundary\r\n",
12138 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12139 "Content-Type: audio/wav\r\n",
12140 "\r\n",
12141 "RIFFtest\r\n",
12142 "--ferrum-test-boundary--\r\n"
12143 );
12144 let response = post_multipart(
12145 router_without_llm(),
12146 "/v1/audio/transcriptions",
12147 boundary,
12148 body,
12149 )
12150 .await;
12151 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
12152 let body = response_json(response).await;
12153 assert_eq!(body["error"]["type"], "service_unavailable_error");
12154 assert_eq!(body["error"]["param"], Value::Null);
12155 }
12156
12157 #[tokio::test]
12158 async fn route_transcriptions_contract_uses_stub_engine() {
12159 let boundary = "ferrum-test-boundary";
12160 let body = concat!(
12161 "--ferrum-test-boundary\r\n",
12162 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12163 "Content-Type: audio/wav\r\n",
12164 "\r\n",
12165 "RIFFtest\r\n",
12166 "--ferrum-test-boundary\r\n",
12167 "Content-Disposition: form-data; name=\"language\"\r\n",
12168 "\r\n",
12169 "en\r\n",
12170 "--ferrum-test-boundary\r\n",
12171 "Content-Disposition: form-data; name=\"response_format\"\r\n",
12172 "\r\n",
12173 "json\r\n",
12174 "--ferrum-test-boundary--\r\n"
12175 );
12176 let response = post_multipart(
12177 router_with_stub_transcribe(),
12178 "/v1/audio/transcriptions",
12179 boundary,
12180 body,
12181 )
12182 .await;
12183 assert_eq!(response.status(), AxumStatusCode::OK);
12184 let body = response_json(response).await;
12185 assert_eq!(body["text"], "bytes:8:en");
12186 }
12187
12188 #[tokio::test]
12189 async fn route_transcriptions_rejects_unsupported_response_format() {
12190 let boundary = "ferrum-test-boundary";
12191 let body = concat!(
12192 "--ferrum-test-boundary\r\n",
12193 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
12194 "Content-Type: audio/wav\r\n",
12195 "\r\n",
12196 "RIFFtest\r\n",
12197 "--ferrum-test-boundary\r\n",
12198 "Content-Disposition: form-data; name=\"response_format\"\r\n",
12199 "\r\n",
12200 "text\r\n",
12201 "--ferrum-test-boundary--\r\n"
12202 );
12203 let response = post_multipart(
12204 router_with_stub_transcribe(),
12205 "/v1/audio/transcriptions",
12206 boundary,
12207 body,
12208 )
12209 .await;
12210 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12211 let body = response_json(response).await;
12212 assert_eq!(body["error"]["type"], "invalid_request_error");
12213 assert_eq!(body["error"]["param"], "response_format");
12214 }
12215
12216 #[tokio::test]
12217 async fn route_transcriptions_rejects_missing_file_with_field_param() {
12218 let boundary = "ferrum-test-boundary";
12219 let body = concat!(
12220 "--ferrum-test-boundary\r\n",
12221 "Content-Disposition: form-data; name=\"language\"\r\n",
12222 "\r\n",
12223 "en\r\n",
12224 "--ferrum-test-boundary--\r\n"
12225 );
12226 let response = post_multipart(
12227 router_with_stub_transcribe(),
12228 "/v1/audio/transcriptions",
12229 boundary,
12230 body,
12231 )
12232 .await;
12233 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12234 let body = response_json(response).await;
12235 assert_eq!(body["error"]["type"], "invalid_request_error");
12236 assert_eq!(body["error"]["param"], "file");
12237 }
12238
12239 #[tokio::test]
12240 async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
12241 let response = post_json(
12242 router_with_stub_transcribe(),
12243 "/v1/audio/transcriptions",
12244 json!({}),
12245 )
12246 .await;
12247 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12248 let body = response_json(response).await;
12249 assert_eq!(body["error"]["type"], "invalid_request_error");
12250 assert_eq!(body["error"]["param"], Value::Null);
12251 assert!(body["error"]["message"]
12252 .as_str()
12253 .unwrap()
12254 .contains("invalid transcriptions request"));
12255 }
12256
12257 #[tokio::test]
12258 async fn route_speech_engine_unavailable_maps_to_503() {
12259 let response = post_json(
12260 router_without_llm(),
12261 "/v1/audio/speech",
12262 json!({
12263 "model": "tts-model",
12264 "input": "hello",
12265 "voice": "default"
12266 }),
12267 )
12268 .await;
12269 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
12270 let body = response_json(response).await;
12271 assert_eq!(body["error"]["type"], "service_unavailable_error");
12272 assert_eq!(body["error"]["param"], Value::Null);
12273 }
12274
12275 #[tokio::test]
12276 async fn route_speech_contract_uses_stub_engine() {
12277 let response = post_json(
12278 router_with_stub_tts(),
12279 "/v1/audio/speech",
12280 json!({
12281 "model": "stub-tts",
12282 "input": "hello",
12283 "voice": "default",
12284 "response_format": "wav",
12285 "language": "english"
12286 }),
12287 )
12288 .await;
12289 assert_eq!(response.status(), AxumStatusCode::OK);
12290 assert_eq!(
12291 response.headers().get(header::CONTENT_TYPE).unwrap(),
12292 "audio/wav"
12293 );
12294 let body = response_bytes(response).await;
12295 assert!(body.len() > 44, "WAV should include header and PCM data");
12296 assert_eq!(&body[0..4], b"RIFF");
12297 assert_eq!(&body[8..12], b"WAVE");
12298 }
12299
12300 #[tokio::test]
12301 async fn route_speech_streaming_contract_uses_stub_engine() {
12302 let response = post_json(
12303 router_with_stub_tts(),
12304 "/v1/audio/speech",
12305 json!({
12306 "model": "stub-tts",
12307 "input": "hello",
12308 "voice": "default",
12309 "response_format": "wav",
12310 "stream": true
12311 }),
12312 )
12313 .await;
12314 assert_eq!(response.status(), AxumStatusCode::OK);
12315 assert_eq!(
12316 response.headers().get(header::CONTENT_TYPE).unwrap(),
12317 "audio/wav"
12318 );
12319 assert_eq!(
12320 response.headers().get(header::TRANSFER_ENCODING).unwrap(),
12321 "chunked"
12322 );
12323 let body = response_bytes(response).await;
12324 assert!(body.len() > 44, "streaming WAV should include audio bytes");
12325 assert_eq!(&body[0..4], b"RIFF");
12326 assert_eq!(&body[8..12], b"WAVE");
12327 }
12328
12329 #[tokio::test]
12330 async fn route_speech_pcm_response_format_returns_raw_pcm() {
12331 let response = post_json(
12332 router_with_stub_tts(),
12333 "/v1/audio/speech",
12334 json!({
12335 "model": "stub-tts",
12336 "input": "hello",
12337 "voice": "default",
12338 "response_format": "pcm"
12339 }),
12340 )
12341 .await;
12342 assert_eq!(response.status(), AxumStatusCode::OK);
12343 assert_eq!(
12344 response.headers().get(header::CONTENT_TYPE).unwrap(),
12345 "audio/pcm"
12346 );
12347 let body = response_bytes(response).await;
12348 assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
12349 assert_eq!(&body[0..2], &[0, 0]);
12350 assert_ne!(&body[0..4], b"RIFF");
12351 }
12352
12353 #[tokio::test]
12354 async fn route_speech_rejects_unsupported_response_format() {
12355 let response = post_json(
12356 router_with_stub_tts(),
12357 "/v1/audio/speech",
12358 json!({
12359 "model": "stub-tts",
12360 "input": "hello",
12361 "voice": "default",
12362 "response_format": "mp3"
12363 }),
12364 )
12365 .await;
12366 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12367 let body = response_json(response).await;
12368 assert_eq!(body["error"]["type"], "invalid_request_error");
12369 assert_eq!(body["error"]["param"], "response_format");
12370 }
12371
12372 #[tokio::test]
12373 async fn route_speech_invalid_json_maps_to_openai_error() {
12374 let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
12375 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12376 let body = response_json(response).await;
12377 assert_eq!(body["error"]["type"], "invalid_request_error");
12378 assert_eq!(body["error"]["param"], Value::Null);
12379 assert!(body["error"]["message"]
12380 .as_str()
12381 .unwrap()
12382 .contains("invalid speech request"));
12383 }
12384
12385 #[tokio::test]
12386 async fn route_completions_generation_failure_maps_to_500() {
12387 let response = post_json(
12388 router_with_failing_llm(),
12389 "/v1/completions",
12390 json!({
12391 "model": "failing-model",
12392 "prompt": "complete me"
12393 }),
12394 )
12395 .await;
12396 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
12397 let body = response_json(response).await;
12398 assert_eq!(body["error"]["type"], "internal_server_error");
12399 assert!(body["error"]["message"]
12400 .as_str()
12401 .unwrap()
12402 .contains("stub generation failed"));
12403 }
12404
12405 #[tokio::test]
12406 async fn route_completions_stream_start_failure_maps_to_500_before_sse() {
12407 let response = post_json(
12408 router_with_failing_llm(),
12409 "/v1/completions",
12410 json!({
12411 "model": "failing-model",
12412 "prompt": "complete me",
12413 "stream": true
12414 }),
12415 )
12416 .await;
12417 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
12418 let body = response_json(response).await;
12419 assert_eq!(body["error"]["type"], "internal_server_error");
12420 assert!(body["error"]["message"]
12421 .as_str()
12422 .unwrap()
12423 .contains("stub stream failed"));
12424 }
12425
12426 #[tokio::test]
12427 async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
12428 let response = post_json(
12429 router_with_stream_chunk_failing_llm(),
12430 "/v1/completions",
12431 json!({
12432 "model": "failing-model",
12433 "prompt": "complete me",
12434 "stream": true
12435 }),
12436 )
12437 .await;
12438 assert_eq!(response.status(), AxumStatusCode::OK);
12439 let body = response_text(response).await;
12440 assert_openai_stream_error(&body, "stub stream chunk failed");
12441 }
12442
12443 #[tokio::test]
12444 async fn route_completions_contract_uses_stub_engine() {
12445 let response = post_json(
12446 router_with_stub("done"),
12447 "/v1/completions",
12448 json!({
12449 "model": "stub-model",
12450 "prompt": "complete me",
12451 "max_tokens": 8,
12452 "temperature": 0.0
12453 }),
12454 )
12455 .await;
12456 assert_eq!(response.status(), AxumStatusCode::OK);
12457 let body = response_json(response).await;
12458 assert_eq!(body["object"], "text_completion");
12459 assert_eq!(body["choices"][0]["text"], "done");
12460 assert_eq!(body["usage"]["prompt_tokens"], 7);
12461 assert_eq!(body["usage"]["completion_tokens"], 2);
12462 }
12463
12464 #[tokio::test]
12465 async fn route_completions_public_alias_maps_to_internal_model() {
12466 let engine = Arc::new(CapturingLlm::new());
12467 let registry = ServedModelRegistry::try_new(
12468 "qwen3",
12469 ServedModelKind::Llm,
12470 vec!["served-alias".to_string()],
12471 vec![],
12472 )
12473 .unwrap();
12474 let router = AxumServer::from_llm(engine.clone())
12475 .with_served_model_registry(registry)
12476 .build_router();
12477 let response = post_json(
12478 router,
12479 "/v1/completions",
12480 json!({"model": "served-alias", "prompt": "complete me"}),
12481 )
12482 .await;
12483
12484 assert_eq!(response.status(), AxumStatusCode::OK);
12485 assert_eq!(response_json(response).await["model"], "served-alias");
12486 assert_eq!(engine.last_request().model_id, ModelId::new("qwen3"));
12487 }
12488
12489 #[tokio::test]
12490 async fn route_completions_streaming_contract_uses_stub_engine() {
12491 let response = post_json(
12492 router_with_stub("done"),
12493 "/v1/completions",
12494 json!({
12495 "model": "stub-model",
12496 "prompt": "complete me",
12497 "max_tokens": 8,
12498 "temperature": 0.0,
12499 "stream": true
12500 }),
12501 )
12502 .await;
12503 assert_eq!(response.status(), AxumStatusCode::OK);
12504 let body = response_text(response).await;
12505 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12506 assert!(
12507 body.contains("\"object\":\"text_completion\""),
12508 "missing completion chunk: {body}"
12509 );
12510 assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
12511 assert!(
12512 body.contains("\"choices\":[],\"usage\""),
12513 "missing separate usage chunk: {body}"
12514 );
12515 assert!(
12516 body.contains("\"prompt_tokens\":5"),
12517 "stream usage should come from engine token usage: {body}"
12518 );
12519 assert!(
12520 body.contains("\"completion_tokens\":1"),
12521 "stream completion usage should come from engine token usage: {body}"
12522 );
12523 }
12524
12525 #[tokio::test]
12526 async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
12527 let response = post_json(
12528 router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
12529 "/v1/completions",
12530 json!({
12531 "model": "stub-model",
12532 "prompt": "complete me",
12533 "max_tokens": 2,
12534 "temperature": 0.0,
12535 "stream": true
12536 }),
12537 )
12538 .await;
12539 assert_eq!(response.status(), AxumStatusCode::OK);
12540 let body = response_text(response).await;
12541 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
12542 assert!(
12543 body.contains("\"text\":\"do\""),
12544 "missing first chunk: {body}"
12545 );
12546 assert!(
12547 body.contains("\"text\":\"ne\""),
12548 "missing second chunk: {body}"
12549 );
12550 assert!(
12551 body.contains("\"choices\":[],\"usage\""),
12552 "missing separate usage chunk from final engine chunk: {body}"
12553 );
12554 assert!(
12555 body.contains("\"prompt_tokens\":5"),
12556 "stream usage should come from engine final usage: {body}"
12557 );
12558 }
12559
12560 #[tokio::test]
12561 async fn route_completions_invalid_json_maps_to_openai_error() {
12562 let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
12563 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12564 let body = response_json(response).await;
12565 assert_eq!(body["error"]["type"], "invalid_request_error");
12566 assert_eq!(body["error"]["param"], Value::Null);
12567 assert!(body["error"]["message"]
12568 .as_str()
12569 .unwrap()
12570 .contains("invalid completions request"));
12571 }
12572
12573 #[tokio::test]
12574 async fn route_completions_rejects_unsupported_fields_explicitly() {
12575 for (extra, param) in [
12576 (json!({"n": 2}), "n"),
12577 (json!({"logprobs": 3}), "logprobs"),
12578 (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
12579 ] {
12580 let mut body = json!({
12581 "model": "stub-model",
12582 "prompt": "complete me"
12583 });
12584 body.as_object_mut()
12585 .expect("object")
12586 .extend(extra.as_object().expect("extra object").clone());
12587 let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
12588 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
12589 let body = response_json(response).await;
12590 assert_eq!(body["error"]["type"], "invalid_request_error");
12591 assert_eq!(body["error"]["param"], param);
12592 }
12593 }
12594
12595 #[tokio::test]
12596 async fn streaming_completions_do_not_synthesize_whitespace_usage() {
12597 let response = post_json(
12598 router_with_stub_without_stream_usage("done"),
12599 "/v1/completions",
12600 json!({
12601 "model": "stub-model",
12602 "prompt": "one two three four",
12603 "stream": true
12604 }),
12605 )
12606 .await;
12607 assert_eq!(response.status(), AxumStatusCode::OK);
12608 let body = response_text(response).await;
12609 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12610 assert!(
12611 !body.contains("\"usage\":{\"prompt_tokens\""),
12612 "server must not synthesize whitespace-count completion usage: {body}"
12613 );
12614 }
12615
12616 #[tokio::test]
12617 async fn chat_rejects_n_not_one_with_openai_error_param() {
12618 let request = chat_request(json!({"n": 2}));
12619 let err = chat_completions_handler(
12620 State(state_with_stub("unused")),
12621 HeaderMap::new(),
12622 Ok(Json(request)),
12623 )
12624 .await
12625 .expect_err("n=2 should reject");
12626 let (status, body) = error_json(err).await;
12627 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12628 assert_eq!(body["error"]["type"], "invalid_request_error");
12629 assert_eq!(body["error"]["param"], "n");
12630 }
12631
12632 #[tokio::test]
12633 async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
12634 for (extra, param) in [
12635 (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
12636 (json!({"logprobs": true}), "logprobs"),
12637 (json!({"top_logprobs": 2}), "top_logprobs"),
12638 ] {
12639 let request = chat_request(extra);
12640 let err = chat_completions_handler(
12641 State(state_with_stub("unused")),
12642 HeaderMap::new(),
12643 Ok(Json(request)),
12644 )
12645 .await
12646 .expect_err("unsupported field should reject");
12647 let (status, body) = error_json(err).await;
12648 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
12649 assert_eq!(body["error"]["param"], param);
12650 assert_eq!(body["error"]["type"], "invalid_request_error");
12651 }
12652 }
12653
12654 #[tokio::test]
12655 async fn chat_stream_options_include_usage_controls_stream_usage() {
12656 let request = chat_request(json!({
12657 "stream": true,
12658 "stream_options": {"include_usage": true}
12659 }));
12660 let response = chat_completions_handler(
12661 State(state_with_stub("ok")),
12662 HeaderMap::new(),
12663 Ok(Json(request)),
12664 )
12665 .await
12666 .expect("stream response");
12667 assert_eq!(response.status(), AxumStatusCode::OK);
12668 let body = response_text(response).await;
12669 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12670 assert!(
12671 body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
12672 "include_usage=true should emit stream usage: {body}"
12673 );
12674 assert!(
12675 body.contains("\"choices\":[],\"usage\""),
12676 "include_usage=true should use a separate usage chunk: {body}"
12677 );
12678 assert!(
12679 body.contains("\"prompt_tokens\":5"),
12680 "stream usage should come from engine token usage: {body}"
12681 );
12682
12683 let request = chat_request(json!({"stream": true}));
12684 let response = chat_completions_handler(
12685 State(state_with_stub("ok")),
12686 HeaderMap::new(),
12687 Ok(Json(request)),
12688 )
12689 .await
12690 .expect("stream response");
12691 let body = response_text(response).await;
12692 assert!(
12693 !body.contains("\"usage\":{\"prompt_tokens\""),
12694 "stream usage should be omitted unless requested: {body}"
12695 );
12696 }
12697
12698 #[tokio::test]
12699 async fn streaming_chat_does_not_synthesize_whitespace_usage() {
12700 let response = post_json(
12701 router_with_stub_without_stream_usage("ok"),
12702 "/v1/chat/completions",
12703 json!({
12704 "model": "stub-model",
12705 "messages": [{"role": "user", "content": "one two three four"}],
12706 "stream": true,
12707 "stream_options": {"include_usage": true}
12708 }),
12709 )
12710 .await;
12711 assert_eq!(response.status(), AxumStatusCode::OK);
12712 let body = response_text(response).await;
12713 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
12714 assert!(
12715 !body.contains("\"usage\":{\"prompt_tokens\""),
12716 "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
12717 );
12718 }
12719
12720 #[test]
12721 fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
12722 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12723 "model": "qwen3",
12724 "messages": [
12725 {"role": "user", "content": "Use the weather tool."},
12726 {
12727 "role": "assistant",
12728 "content": null,
12729 "tool_calls": [{
12730 "id": "call_1",
12731 "type": "function",
12732 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
12733 }]
12734 },
12735 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
12736 ],
12737 "tools": [{
12738 "type": "function",
12739 "function": {
12740 "name": "weather",
12741 "description": "Get weather",
12742 "parameters": {
12743 "type": "object",
12744 "properties": {"city": {"type": "string"}},
12745 "required": ["city"]
12746 }
12747 }
12748 }],
12749 "tool_choice": "auto"
12750 }))
12751 .expect("tool request parses");
12752
12753 validate_chat_request(&request).expect("tool request validates");
12754 let internal = convert_chat_request(&request).expect("convert");
12755 assert!(internal.prompt.contains("\"tools\":[{"));
12756 assert!(internal.prompt.contains("\"type\":\"function\""));
12757 assert!(internal.prompt.contains("\"name\":\"weather\""));
12758 assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
12759 assert!(internal.prompt.contains("\"tool_calls\":[{"));
12760 assert!(internal.prompt.contains("\"id\":\"call_1\""));
12761 assert!(internal
12762 .prompt
12763 .contains("<|im_start|>tool\nsunny<|im_end|>"));
12764 assert_eq!(
12765 internal.metadata["openai_tools"][0]["function"]["name"],
12766 "weather"
12767 );
12768 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12769 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12770 panic!("expected structured chat api_request");
12771 };
12772 assert_eq!(api.messages.len(), 3);
12773 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
12774 assert_eq!(api.messages[2].content, "sunny");
12775 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
12776 assert_eq!(api.tools[0].function.name, "weather");
12777 assert_eq!(
12778 api.tool_choice,
12779 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12780 );
12781 assert_eq!(
12782 api.messages[1].tool_calls[0].function.arguments,
12783 "{\"city\":\"Paris\"}"
12784 );
12785 }
12786
12787 #[test]
12788 fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
12789 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12790 "model": "served-alias",
12791 "messages": [{"role": "user", "content": "Use the weather tool."}],
12792 "tools": [{
12793 "type": "function",
12794 "function": {
12795 "name": "weather",
12796 "description": "Get weather",
12797 "parameters": {
12798 "type": "object",
12799 "properties": {"city": {"type": "string"}},
12800 "required": ["city"]
12801 }
12802 }
12803 }]
12804 }))
12805 .expect("tool request parses");
12806
12807 validate_chat_request(&request).expect("tool request validates");
12808 let internal = convert_chat_request(&request).expect("convert");
12809 assert!(internal.prompt.contains("\"tools\":[{"));
12810 assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
12811 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12812 let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
12813 .as_array()
12814 .expect("initial forbidden token list");
12815 assert_eq!(initial_forbidden, &[serde_json::json!(THINK_END_TAG)]);
12816 assert_eq!(
12817 internal.sampling_params.response_format,
12818 ferrum_types::ResponseFormat::Text,
12819 "auto tool choice must preserve native model selection instead of forcing arguments JSON",
12820 );
12821 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
12822 panic!("expected structured chat api_request");
12823 };
12824 assert_eq!(
12825 api.tool_choice,
12826 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
12827 );
12828 }
12829
12830 #[test]
12831 fn omitted_tool_choice_uses_native_template_protocol_without_hard_schema() {
12832 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12833 "model": "served-alias",
12834 "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
12835 "tools": [{
12836 "type": "function",
12837 "function": {
12838 "name": "get_weather",
12839 "description": "查询指定城市的当前天气",
12840 "parameters": {
12841 "type": "object",
12842 "properties": {
12843 "city": {"type": "string"},
12844 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12845 },
12846 "required": ["city"]
12847 }
12848 }
12849 }]
12850 }))
12851 .expect("tool request parses");
12852 let template = ModelChatTemplate::new(
12853 "{% 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 %}",
12854 "function-parameter-xml-template",
12855 );
12856
12857 validate_chat_request(&request).expect("tool request validates");
12858 let internal =
12859 convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
12860 .expect("convert");
12861 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
12862 assert_eq!(
12863 internal.sampling_params.response_format,
12864 ferrum_types::ResponseFormat::Text,
12865 );
12866 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
12867 panic!("expected chat API request");
12868 };
12869 assert_eq!(
12870 api.tool_call_protocol,
12871 ferrum_types::ApiToolCallProtocol::FunctionParameterXml,
12872 );
12873 }
12874
12875 #[test]
12876 fn tool_schema_response_format_bounds_unconstrained_strings() {
12877 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12878 "model": "served-alias",
12879 "messages": [{"role": "user", "content": "Use the selected tool."}],
12880 "tools": [{
12881 "type": "function",
12882 "function": {
12883 "name": "get_weather",
12884 "parameters": {
12885 "type": "object",
12886 "properties": {
12887 "city": {"type": "string"},
12888 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
12889 },
12890 "required": ["city"]
12891 }
12892 }
12893 }],
12894 "tool_choice": {
12895 "type": "function",
12896 "function": {"name": "get_weather"}
12897 }
12898 }))
12899 .expect("tool request parses");
12900
12901 validate_chat_request(&request).expect("tool request validates");
12902 let internal = convert_chat_request(&request).expect("convert");
12903 match internal.sampling_params.response_format {
12904 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
12905 let value: serde_json::Value =
12906 serde_json::from_str(schema).expect("schema should be JSON");
12907 assert_eq!(
12908 value["properties"]["city"]["maxLength"],
12909 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
12910 );
12911 assert_eq!(
12912 value["properties"]["unit"]["enum"],
12913 json!(["celsius", "fahrenheit"])
12914 );
12915 assert!(
12916 value["properties"]["unit"]["maxLength"].is_null(),
12917 "enum string should remain finite via enum instead of maxLength: {value}"
12918 );
12919 }
12920 ref other => panic!("expected forced tool json schema, got {other:?}"),
12921 }
12922 }
12923
12924 #[test]
12925 fn forced_native_tool_choice_preserves_xml_framing_with_a_tool_only_grammar() {
12926 let template = ModelChatTemplate::new(
12927 "{% 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 %}",
12928 "native-tool-fixture",
12929 );
12930 for choice in [
12931 json!({"type":"function","function":{"name":"calc"}}),
12932 json!("required"),
12933 ] {
12934 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12935 "model":"served-alias","messages":[{"role":"user","content":"Use the selected tool."}],
12936 "tools":[
12937 {"type":"function","function":{"name":"calc","parameters":{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}}},
12938 {"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}
12939 ],
12940 "tool_choice":choice
12941 })).unwrap();
12942 validate_chat_request(&request).unwrap();
12943 let internal =
12944 convert_chat_request_with_template_model(&request, "served-alias", Some(&template))
12945 .unwrap();
12946 assert!(internal.requires_structured_output());
12947 assert_eq!(
12948 internal.sampling_params.response_format,
12949 ferrum_types::ResponseFormat::Text
12950 );
12951 assert!(internal.prompt.contains("<function=name>"));
12952 let Some(ferrum_types::ApiRequest::Chat(chat)) = &internal.api_request else {
12953 panic!("chat contract")
12954 };
12955 assert!(chat.requires_native_tool_call());
12956 assert!(chat.allows_tool_name("calc"));
12957 assert_eq!(chat.allows_tool_name("lookup"), choice == json!("required"));
12958 assert_eq!(
12959 internal.sampling_params.structured_output_start,
12960 StructuredOutputStart::Immediate
12961 );
12962 }
12963 }
12964
12965 #[test]
12966 fn harmony_named_tool_choice_preserves_native_protocol_envelope() {
12967 let request: ChatCompletionsRequest = serde_json::from_value(json!({
12968 "model": "gpt-oss-20b-mxfp4",
12969 "messages": [{
12970 "role": "user",
12971 "content": "Call get_weather exactly once with city set to Paris."
12972 }],
12973 "tools": [{
12974 "type": "function",
12975 "function": {
12976 "name": "get_weather",
12977 "parameters": {
12978 "type": "object",
12979 "properties": {"city": {"type": "string"}},
12980 "required": ["city"],
12981 "additionalProperties": false
12982 }
12983 }
12984 }],
12985 "tool_choice": {
12986 "type": "function",
12987 "function": {"name": "get_weather"}
12988 }
12989 }))
12990 .expect("Harmony tool request parses");
12991 let mut template = ModelChatTemplate::new(
12992 "{% 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 %}",
12993 "harmony-tool-template",
12994 );
12995 template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
12996
12997 validate_chat_request(&request).expect("Harmony tool request validates");
12998 let internal =
12999 convert_chat_request_with_template_model(&request, "gpt-oss-20b", Some(&template))
13000 .expect("convert Harmony tool request");
13001
13002 assert!(internal.prompt.ends_with("<|start|>assistant"));
13003 assert_eq!(
13004 internal.sampling_params.model_output_protocol,
13005 ModelOutputProtocol::HarmonyGptOss
13006 );
13007 assert_eq!(
13008 internal.sampling_params.response_format,
13009 ferrum_types::ResponseFormat::Text,
13010 "Harmony must generate its channel/message/call envelope before tool arguments"
13011 );
13012 assert_eq!(
13013 internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY],
13014 json!([]),
13015 "Harmony declares no think delimiter and must not receive the generic structured-call mask"
13016 );
13017 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request else {
13018 panic!("expected chat API request");
13019 };
13020 assert_eq!(
13021 api.tool_choice,
13022 Some(ferrum_types::ApiToolChoice::Function {
13023 tool_type: "function".to_string(),
13024 function: ferrum_types::ApiToolChoiceFunction {
13025 name: "get_weather".to_string(),
13026 },
13027 })
13028 );
13029 }
13030
13031 #[test]
13032 fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
13033 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13034 "model": "served-alias",
13035 "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
13036 "tools": [{
13037 "type": "function",
13038 "function": {
13039 "name": "capture_quality_marker",
13040 "description": "Record one marker.",
13041 "parameters": {
13042 "type": "object",
13043 "properties": {
13044 "marker": {"type": "string", "enum": ["ferrum0401"]},
13045 "checksum": {"type": "string", "enum": ["S0004"]}
13046 },
13047 "required": ["marker", "checksum"]
13048 }
13049 }
13050 }],
13051 "tool_choice": "required"
13052 }))
13053 .expect("tool request parses");
13054
13055 validate_chat_request(&request).expect("tool request validates");
13056 let internal = convert_chat_request(&request).expect("convert");
13057
13058 assert!(
13059 !internal.prompt.contains(
13060 "Output only a single JSON object containing the selected function arguments"
13061 ),
13062 "{}",
13063 internal.prompt
13064 );
13065 assert!(
13066 internal.prompt.contains("\"tool_choice\":\"required\""),
13067 "{}",
13068 internal.prompt
13069 );
13070 assert_eq!(internal.metadata["openai_tool_choice"], "required");
13071 match internal.sampling_params.response_format {
13072 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
13073 assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
13074 assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
13075 }
13076 ref other => panic!("expected forced tool json schema, got {other:?}"),
13077 }
13078 }
13079
13080 #[test]
13081 fn required_tool_choice_suppresses_conflicting_response_format_instruction() {
13082 let request: ChatCompletionsRequest =
13083 serde_json::from_value(required_tool_with_strict_response_format_request(false))
13084 .expect("request parses");
13085
13086 validate_chat_request(&request).expect("request validates");
13087 let internal = convert_chat_request(&request).expect("convert");
13088
13089 assert!(
13090 !internal.prompt.contains("response_format requires"),
13091 "required tool output must not receive a conflicting content-schema instruction: {}",
13092 internal.prompt
13093 );
13094 let ferrum_types::ResponseFormat::JsonSchema(schema) =
13095 internal.sampling_params.response_format
13096 else {
13097 panic!("single required tool must use its argument schema");
13098 };
13099 let schema: Value = serde_json::from_str(&schema).expect("tool schema JSON");
13100 assert!(schema["properties"].get("city").is_some(), "{schema}");
13101 assert!(schema["properties"].get("answer").is_none(), "{schema}");
13102 }
13103
13104 #[test]
13105 fn required_multiple_tools_do_not_force_the_first_tool_schema() {
13106 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13107 "model": "stub-model",
13108 "messages": [{"role": "user", "content": "Use the appropriate tool."}],
13109 "tools": [
13110 {
13111 "type": "function",
13112 "function": {
13113 "name": "weather",
13114 "parameters": {
13115 "type": "object",
13116 "properties": {"city": {"type": "string"}},
13117 "required": ["city"]
13118 }
13119 }
13120 },
13121 {
13122 "type": "function",
13123 "function": {
13124 "name": "calendar",
13125 "parameters": {
13126 "type": "object",
13127 "properties": {"date": {"type": "string"}},
13128 "required": ["date"]
13129 }
13130 }
13131 }
13132 ],
13133 "tool_choice": "required"
13134 }))
13135 .expect("request parses");
13136
13137 validate_chat_request(&request).expect("request validates");
13138 let internal = convert_chat_request(&request).expect("convert");
13139 assert_eq!(
13140 internal.sampling_params.response_format,
13141 ferrum_types::ResponseFormat::Text,
13142 "required permits either declared tool, so guided decoding cannot bind the first tool's arguments"
13143 );
13144 }
13145
13146 #[test]
13147 fn omitted_single_unrelated_tool_keeps_text_response_format() {
13148 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13149 "model": "served-alias",
13150 "messages": [{"role": "user", "content": "讲一个短笑话。"}],
13151 "tools": [{
13152 "type": "function",
13153 "function": {
13154 "name": "get_weather",
13155 "description": "查询指定城市的当前天气",
13156 "parameters": {
13157 "type": "object",
13158 "properties": {"city": {"type": "string"}},
13159 "required": ["city"]
13160 }
13161 }
13162 }]
13163 }))
13164 .expect("tool request parses");
13165
13166 validate_chat_request(&request).expect("tool request validates");
13167 let internal = convert_chat_request(&request).expect("convert");
13168 assert_eq!(
13169 internal.sampling_params.response_format,
13170 ferrum_types::ResponseFormat::Text
13171 );
13172 }
13173
13174 #[test]
13175 fn tool_choice_none_omits_tools_from_model_template_prompt() {
13176 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13177 "model": "served-alias",
13178 "messages": [
13179 {"role": "user", "content": "Use the weather tool if needed."},
13180 {
13181 "role": "assistant",
13182 "content": null,
13183 "tool_calls": [{
13184 "id": "call_1",
13185 "type": "function",
13186 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
13187 }]
13188 },
13189 {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
13190 ],
13191 "tools": [{
13192 "type": "function",
13193 "function": {"name": "weather", "parameters": {"type": "object"}}
13194 }],
13195 "tool_choice": "none"
13196 }))
13197 .expect("tool_choice none request parses");
13198 let template = ModelChatTemplate::new(
13199 "{% 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 %}",
13200 "tool-choice-none-template",
13201 );
13202
13203 validate_chat_request(&request).expect("tool_choice none request validates");
13204 let internal = convert_chat_request_with_template_model(
13205 &request,
13206 "served-template-model",
13207 Some(&template),
13208 )
13209 .expect("convert");
13210 assert!(
13211 !internal.prompt.contains("<tools>"),
13212 "tool_choice none must not expose tools to the model template: {}",
13213 internal.prompt
13214 );
13215 assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
13216 assert_eq!(
13217 internal.metadata["openai_tools"][0]["function"]["name"],
13218 "weather"
13219 );
13220 assert_eq!(internal.metadata["openai_tool_choice"], "none");
13221 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13222 panic!("expected structured chat api_request");
13223 };
13224 assert_eq!(api.tools[0].function.name, "weather");
13225 assert_eq!(
13226 api.tool_choice,
13227 Some(ferrum_types::ApiToolChoice::Mode("none".into()))
13228 );
13229 }
13230
13231 #[test]
13232 fn specific_tool_choice_parses_into_structured_api_request() {
13233 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13234 "model": "qwen3",
13235 "messages": [{"role": "user", "content": "Use the selected tool."}],
13236 "tools": [
13237 {
13238 "type": "function",
13239 "function": {"name": "weather", "parameters": {"type": "object"}}
13240 },
13241 {
13242 "type": "function",
13243 "function": {"name": "calendar", "parameters": {"type": "object"}}
13244 }
13245 ],
13246 "tool_choice": {
13247 "type": "function",
13248 "function": {"name": "weather"}
13249 }
13250 }))
13251 .expect("specific tool_choice request parses");
13252
13253 validate_chat_request(&request).expect("specific tool_choice validates");
13254 let internal = convert_chat_request(&request).expect("convert");
13255 assert!(internal.prompt.contains("\"tool_choice\":{"));
13256 assert!(internal.prompt.contains("\"name\":\"weather\""));
13257 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13258 panic!("expected structured chat api_request");
13259 };
13260 assert_eq!(
13261 api.tool_choice,
13262 Some(ferrum_types::ApiToolChoice::Function {
13263 tool_type: "function".to_string(),
13264 function: ferrum_types::ApiToolChoiceFunction {
13265 name: "weather".to_string()
13266 },
13267 })
13268 );
13269
13270 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
13271 "model": "qwen3",
13272 "messages": [{"role": "user", "content": "Use the selected tool."}],
13273 "tools": [{
13274 "type": "function",
13275 "function": {"name": "weather", "parameters": {"type": "object"}}
13276 }],
13277 "tool_choice": {
13278 "type": "function",
13279 "function": {"name": "calendar"}
13280 }
13281 }))
13282 .expect("invalid specific tool_choice request parses");
13283 let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
13284 match err {
13285 ServerError::InvalidRequest { param, .. } => {
13286 assert_eq!(param.as_deref(), Some("tool_choice"));
13287 }
13288 other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
13289 }
13290 }
13291
13292 #[test]
13293 fn legacy_function_role_messages_parse_into_structured_api_request() {
13294 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13295 "model": "mystery-model",
13296 "messages": [
13297 {"role": "user", "content": "Call weather."},
13298 {
13299 "role": "assistant",
13300 "content": null,
13301 "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
13302 },
13303 {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
13304 ],
13305 "functions": [{
13306 "name": "weather",
13307 "parameters": {
13308 "type": "object",
13309 "properties": {"city": {"type": "string"}},
13310 "required": ["city"]
13311 }
13312 }],
13313 "function_call": "auto"
13314 }))
13315 .expect("legacy function request parses");
13316
13317 validate_chat_request(&request).expect("legacy function request validates");
13318 let internal = convert_chat_request(&request).expect("convert");
13319 assert!(
13320 internal
13321 .prompt
13322 .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
13323 "legacy function role should be preserved in fallback template: {}",
13324 internal.prompt
13325 );
13326 assert_eq!(
13327 internal.metadata["openai_legacy_functions"][0]["name"],
13328 "weather"
13329 );
13330 assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
13331 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13332 panic!("expected structured chat api_request");
13333 };
13334 assert_eq!(api.messages.len(), 3);
13335 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
13336 assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
13337 assert_eq!(
13338 api.messages[1]
13339 .function_call
13340 .as_ref()
13341 .map(|call| call.name.as_str()),
13342 Some("weather")
13343 );
13344 assert_eq!(api.legacy_functions[0].name, "weather");
13345 assert_eq!(
13346 api.legacy_function_call,
13347 Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
13348 );
13349 }
13350
13351 #[test]
13352 fn specific_legacy_function_call_parses_into_structured_api_request() {
13353 let request: ChatCompletionsRequest = serde_json::from_value(json!({
13354 "model": "mystery-model",
13355 "messages": [{"role": "user", "content": "Use the selected function."}],
13356 "functions": [
13357 {"name": "weather", "parameters": {"type": "object"}},
13358 {"name": "calendar", "parameters": {"type": "object"}}
13359 ],
13360 "function_call": {"name": "weather"}
13361 }))
13362 .expect("specific function_call request parses");
13363
13364 validate_chat_request(&request).expect("specific function_call validates");
13365 let internal = convert_chat_request(&request).expect("convert");
13366 assert!(internal.prompt.contains("\"function_call\":{"));
13367 assert!(internal.prompt.contains("\"name\":\"weather\""));
13368 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13369 panic!("expected structured chat api_request");
13370 };
13371 assert_eq!(
13372 api.legacy_function_call,
13373 Some(ferrum_types::ApiFunctionCallChoice::Function {
13374 name: "weather".to_string(),
13375 })
13376 );
13377
13378 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
13379 "model": "mystery-model",
13380 "messages": [{"role": "user", "content": "Use the selected function."}],
13381 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
13382 "function_call": {"name": "calendar"}
13383 }))
13384 .expect("invalid specific function_call request parses");
13385 let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
13386 match err {
13387 ServerError::InvalidRequest { param, .. } => {
13388 assert_eq!(param.as_deref(), Some("function_call"));
13389 }
13390 other => panic!("expected invalid_request_error for function_call, got {other:?}"),
13391 }
13392 }
13393
13394 #[test]
13395 fn stream_text_delta_handles_unicode_boundaries() {
13396 let mut sent_len = 0usize;
13397 assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
13398 assert_eq!(sent_len, "你好".len());
13399 assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
13400 assert_eq!(sent_len, "你好世界".len());
13401 }
13402
13403 #[test]
13404 fn stream_text_delta_recovers_from_non_boundary_offset() {
13405 let mut sent_len = 1usize;
13406 assert_eq!(stream_text_delta("你好", &mut sent_len), "");
13407 assert_eq!(sent_len, "你好".len());
13408 }
13409
13410 #[test]
13411 fn assistant_tool_call_serializes_openai_shape() {
13412 let message = ChatMessage {
13413 role: MessageRole::Assistant,
13414 content: String::new(),
13415 reasoning: None,
13416 name: None,
13417 tool_calls: Some(vec![ChatToolCall {
13418 index: None,
13419 id: "call_1".to_string(),
13420 tool_type: "function".to_string(),
13421 function: ChatFunctionCall {
13422 name: "weather".to_string(),
13423 arguments: "{\"city\":\"Paris\"}".to_string(),
13424 },
13425 }]),
13426 tool_call_id: None,
13427 function_call: None,
13428 };
13429 let value = serde_json::to_value(message).expect("serialize");
13430 assert_eq!(value["role"], "assistant");
13431 assert_eq!(value["tool_calls"][0]["type"], "function");
13432 assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
13433 }
13434
13435 #[test]
13436 fn unsupported_multimodal_content_is_not_silently_dropped() {
13437 let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
13438 "model": "stub-model",
13439 "messages": [{
13440 "role": "user",
13441 "content": [
13442 {"type": "text", "text": "describe this"},
13443 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
13444 ]
13445 }]
13446 }))
13447 .expect_err("unsupported content part should fail parsing");
13448 assert!(
13449 err.to_string()
13450 .contains("unsupported message content part type"),
13451 "unexpected error: {err}"
13452 );
13453 }
13454
13455 #[tokio::test]
13456 async fn completions_endpoint_uses_stub_engine() {
13457 let request = CompletionsRequest {
13458 model: "stub-model".to_string(),
13459 prompt: CompletionPrompt::Text("complete me".to_string()),
13460 max_tokens: Some(8),
13461 temperature: Some(0.0),
13462 top_p: None,
13463 n: None,
13464 stream: None,
13465 stop: None,
13466 logprobs: None,
13467 logit_bias: None,
13468 };
13469 let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
13470 .await
13471 .expect("completion response");
13472 assert_eq!(response.status(), AxumStatusCode::OK);
13473 let body = response_json(response).await;
13474 assert_eq!(body["object"], "text_completion");
13475 assert_eq!(body["choices"][0]["text"], "done");
13476 assert_eq!(body["usage"]["prompt_tokens"], 7);
13477 assert_eq!(body["usage"]["completion_tokens"], 2);
13478 }
13479
13480 #[tokio::test]
13481 async fn route_completions_rejects_non_string_prompt_with_field_param() {
13482 for prompt in [
13483 json!(["a", "b"]),
13484 json!({"text": "complete me"}),
13485 Value::Null,
13486 ] {
13487 let response = post_json(
13488 router_with_stub("unused"),
13489 "/v1/completions",
13490 json!({
13491 "model": "stub-model",
13492 "prompt": prompt
13493 }),
13494 )
13495 .await;
13496 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13497 let body = response_json(response).await;
13498 assert_eq!(body["error"]["type"], "invalid_request_error");
13499 assert_eq!(body["error"]["param"], "prompt");
13500 }
13501
13502 let response = post_json(
13503 router_with_stub("unused"),
13504 "/v1/completions",
13505 json!({"model": "stub-model"}),
13506 )
13507 .await;
13508 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13509 let body = response_json(response).await;
13510 assert_eq!(body["error"]["type"], "invalid_request_error");
13511 assert_eq!(body["error"]["param"], "prompt");
13512 }
13513
13514 #[tokio::test]
13515 async fn stream_options_without_stream_is_invalid() {
13516 let request = chat_request(json!({"stream_options": {"include_usage": true}}));
13517 let err = chat_completions_handler(
13518 State(state_with_stub("unused")),
13519 HeaderMap::new(),
13520 Ok(Json(request)),
13521 )
13522 .await
13523 .expect_err("stream_options without stream should reject");
13524 let (status, body) = error_json(err).await;
13525 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13526 assert_eq!(body["error"]["param"], "stream_options");
13527 assert_eq!(body["error"]["type"], "invalid_request_error");
13528 }
13529
13530 #[tokio::test]
13531 async fn unknown_stream_option_is_rejected_instead_of_ignored() {
13532 let response = post_json(
13533 router_with_stub("unused"),
13534 "/v1/chat/completions",
13535 json!({
13536 "model": "stub-model",
13537 "messages": [{"role": "user", "content": "hello"}],
13538 "stream": true,
13539 "stream_options": {"continuous_usage_stats": true}
13540 }),
13541 )
13542 .await;
13543
13544 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
13545 let body = response_json(response).await;
13546 assert_eq!(body["error"]["type"], "invalid_request_error");
13547 assert!(
13548 body["error"]["message"]
13549 .as_str()
13550 .unwrap_or_default()
13551 .contains("invalid chat completions request"),
13552 "body: {body}"
13553 );
13554 }
13555
13556 #[tokio::test]
13557 async fn json_object_rejects_markdown_fence_instead_of_repairing() {
13558 let request = chat_request(json!({
13559 "response_format": {"type": "json_object"}
13560 }));
13561 let err = chat_completions_handler(
13562 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
13563 HeaderMap::new(),
13564 Ok(Json(request)),
13565 )
13566 .await
13567 .expect_err("fenced json_object must fail");
13568 let (status, body) = error_json(err).await;
13569 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13570 assert_eq!(body["error"]["type"], "internal_server_error");
13571 assert!(body["error"]["message"]
13572 .as_str()
13573 .unwrap_or_default()
13574 .contains("response_format.json_object: invalid JSON"));
13575 }
13576
13577 #[tokio::test]
13578 async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
13579 let response = post_json(
13580 router_with_stub_stream_chunks(&[
13581 "<think>\n好的,我需要输出 JSON。",
13582 "\n</think>\n\n",
13583 "{\"name\":\"李四\",\"age\":30}",
13584 ]),
13585 "/v1/chat/completions",
13586 json!({
13587 "model": "stub-model",
13588 "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
13589 "stream": true,
13590 "response_format": {"type": "json_object"}
13591 }),
13592 )
13593 .await;
13594 assert_eq!(response.status(), AxumStatusCode::OK);
13595 let body = response_text(response).await;
13596 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
13597 assert!(
13598 body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
13599 "stream should emit clean JSON content: {body}"
13600 );
13601 assert!(
13602 body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
13603 "stream should keep thinking in reasoning field: {body}"
13604 );
13605 assert!(
13606 !body.contains(r#""content":"<think"#)
13607 && !body.contains(r#""content":"好的"#)
13608 && !body.contains(r#""content":"我需要"#),
13609 "thinking text must not leak as streamed content: {body}"
13610 );
13611 }
13612
13613 fn prompt_opened_literal_json_template() -> ModelChatTemplate {
13614 let template = ModelChatTemplate::new(
13615 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>{% endif %}",
13616 "prompt-opened-text-test",
13617 );
13618 assert_eq!(template.output_protocol, ModelOutputProtocol::Text);
13619 assert_eq!(
13620 template.reasoning_protocol,
13621 ModelReasoningProtocol::PromptOpened
13622 );
13623 let request = chat_request(json!({"response_format": {"type": "json_object"}}));
13624 let internal =
13625 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13626 .expect("convert prompt-opened Text request");
13627 assert!(internal.prompt.ends_with("<think>"));
13628 template
13629 }
13630
13631 #[tokio::test]
13632 async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sync() {
13633 let response = post_json(
13634 router_with_stub_and_template(
13635 "reason</think>\n{\"text\":\"<think>literal</think>\"}",
13636 prompt_opened_literal_json_template(),
13637 ),
13638 "/v1/chat/completions",
13639 json!({
13640 "model": "stub-model",
13641 "messages": [{"role": "user", "content": "Return a JSON object."}],
13642 "response_format": {"type": "json_object"}
13643 }),
13644 )
13645 .await;
13646 let status = response.status();
13647 let body = response_json(response).await;
13648 assert_eq!(status, AxumStatusCode::OK, "{body}");
13649 assert!(body.get("error").is_none(), "{body}");
13650 let message = &body["choices"][0]["message"];
13651 assert_eq!(message["content"], r#"{"text":"<think>literal</think>"}"#);
13652 assert_eq!(message["reasoning"], "reason");
13653 assert_eq!(body["choices"][0]["finish_reason"], "stop");
13654 }
13655
13656 #[tokio::test]
13657 async fn json_object_preserves_literal_think_tags_after_prompt_opened_reasoning_sse() {
13658 for chunks in [
13659 vec!["reason</think>\n{\"text\":\"<think>literal</think>\"}"],
13660 vec![
13661 "reason</thi",
13662 "nk>\n{\"text\":\"<thi",
13663 "nk>literal</thi",
13664 "nk>\"}",
13665 ],
13666 ] {
13667 let router = AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(&chunks)))
13668 .with_prompt_template(Some(prompt_opened_literal_json_template()))
13669 .build_router();
13670 let response = post_json(
13671 router,
13672 "/v1/chat/completions",
13673 json!({
13674 "model": "stub-model",
13675 "messages": [{"role": "user", "content": "Return a JSON object."}],
13676 "stream": true,
13677 "stream_options": {"include_usage": true},
13678 "response_format": {"type": "json_object"}
13679 }),
13680 )
13681 .await;
13682 let status = response.status();
13683 let body = response_text(response).await;
13684 assert_eq!(status, AxumStatusCode::OK, "{body}");
13685 let normalized = body.replace("\r\n", "\n");
13686 assert_eq!(normalized.matches("data: [DONE]").count(), 1, "{body}");
13687 assert!(normalized.ends_with("data: [DONE]\n\n"), "{body}");
13688 let events = responses_sse_json_events(&body);
13689 assert!(
13690 events.iter().all(|event| event.get("error").is_none()),
13691 "{body}"
13692 );
13693 let content: String = events
13694 .iter()
13695 .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
13696 .collect();
13697 let reasoning: String = events
13698 .iter()
13699 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
13700 .collect();
13701 assert_eq!(content, r#"{"text":"<think>literal</think>"}"#);
13702 assert_eq!(reasoning, "reason");
13703 assert_eq!(
13704 serde_json::from_str::<Value>(&content).expect("intact JSON body"),
13705 json!({"text": "<think>literal</think>"})
13706 );
13707 let terminals: Vec<_> = events
13708 .iter()
13709 .enumerate()
13710 .filter(|(_, event)| !event["choices"][0]["finish_reason"].is_null())
13711 .collect();
13712 assert_eq!(terminals.len(), 1, "{body}");
13713 let (terminal_index, terminal) = terminals[0];
13714 assert_eq!(terminal["choices"][0]["finish_reason"], "stop");
13715 for event in &events[terminal_index..] {
13716 for field in ["content", "reasoning", "reasoning_content"] {
13717 assert!(
13718 event["choices"][0]["delta"][field]
13719 .as_str()
13720 .unwrap_or_default()
13721 .is_empty(),
13722 "payload after terminal: {event}"
13723 );
13724 }
13725 }
13726 let usages: Vec<_> = events
13727 .iter()
13728 .enumerate()
13729 .filter(|(_, event)| !event["usage"].is_null())
13730 .collect();
13731 assert_eq!(usages.len(), 1, "{body}");
13732 let (usage_index, usage) = usages[0];
13733 assert!(terminal_index < usage_index, "{body}");
13734 assert_eq!(usage_index, events.len() - 1, "usage must be last: {body}");
13735 assert_eq!(usage["choices"], json!([]));
13736 }
13737 }
13738
13739 #[tokio::test]
13740 async fn json_object_rejects_non_json_model_output() {
13741 let request = chat_request(json!({
13742 "response_format": {"type": "json_object"}
13743 }));
13744 let err = chat_completions_handler(
13745 State(state_with_stub("not json")),
13746 HeaderMap::new(),
13747 Ok(Json(request)),
13748 )
13749 .await
13750 .expect_err("invalid json_object must fail");
13751 let (status, body) = error_json(err).await;
13752 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
13753 assert_eq!(body["error"]["type"], "internal_server_error");
13754 assert!(body["error"]["message"]
13755 .as_str()
13756 .unwrap_or_default()
13757 .contains("response_format.json_object"));
13758 }
13759
13760 #[test]
13761 fn one_of_strict_json_schema_reaches_hard_decoder() {
13762 let request = chat_request(json!({
13763 "response_format": {
13764 "type": "json_schema",
13765 "json_schema": {
13766 "name": "unsupported",
13767 "strict": true,
13768 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13769 }
13770 }
13771 }));
13772 validate_chat_request(&request).expect("oneOf strict schema should validate");
13773 let internal = convert_chat_request(&request).expect("convert oneOf strict schema");
13774 let ferrum_types::ResponseFormat::JsonSchema(schema) =
13775 internal.sampling_params.response_format
13776 else {
13777 panic!("strict schema did not reach hard decoder");
13778 };
13779 assert_eq!(
13780 serde_json::from_str::<serde_json::Value>(&schema).unwrap()["oneOf"],
13781 json!([{"type": "string"}, {"type": "integer"}])
13782 );
13783 let schema = serde_json::from_str::<serde_json::Value>(&schema).unwrap();
13784 validate_json_text_against_schema(&schema, r#""answer""#)
13785 .expect("oneOf string branch should pass final validation");
13786 validate_json_text_against_schema(&schema, "7")
13787 .expect("oneOf integer branch should pass final validation");
13788 assert!(validate_json_text_against_schema(&schema, "true").is_err());
13789 }
13790
13791 #[tokio::test]
13792 async fn missing_json_schema_schema_rejects_with_field_param() {
13793 let request = chat_request(json!({
13794 "response_format": {
13795 "type": "json_schema",
13796 "json_schema": {
13797 "name": "missing_schema",
13798 "strict": true
13799 }
13800 }
13801 }));
13802 let err = chat_completions_handler(
13803 State(state_with_stub("unused")),
13804 HeaderMap::new(),
13805 Ok(Json(request)),
13806 )
13807 .await
13808 .expect_err("missing strict schema should reject");
13809 let (status, body) = error_json(err).await;
13810 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
13811 assert_eq!(body["error"]["param"], "response_format.json_schema");
13812 assert_eq!(body["error"]["type"], "invalid_request_error");
13813 assert!(body["error"]["message"]
13814 .as_str()
13815 .unwrap()
13816 .contains("schema is required"));
13817 }
13818
13819 #[test]
13820 fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
13821 let request = chat_request(json!({
13822 "response_format": {
13823 "type": "json_schema",
13824 "json_schema": {
13825 "name": "best_effort",
13826 "strict": false,
13827 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
13828 }
13829 }
13830 }));
13831
13832 validate_chat_request(&request).expect("non-strict schema should not boundary reject");
13833 let internal = convert_chat_request(&request).expect("convert non-strict schema");
13834 assert!(
13835 internal
13836 .prompt
13837 .contains("response_format requires a single valid JSON value"),
13838 "response_format instruction should reach the model prompt: {}",
13839 internal.prompt
13840 );
13841 assert!(
13842 internal.prompt.contains("\"oneOf\""),
13843 "schema should reach the model prompt: {}",
13844 internal.prompt
13845 );
13846 assert_eq!(
13847 internal.sampling_params.response_format,
13848 ferrum_types::ResponseFormat::Text,
13849 "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
13850 );
13851 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
13852 panic!("expected structured chat api_request");
13853 };
13854 assert_eq!(
13855 api.response_format
13856 .as_ref()
13857 .and_then(|format| format.json_schema.as_ref())
13858 .and_then(|schema| schema.strict),
13859 Some(false)
13860 );
13861 }
13862
13863 #[test]
13864 fn json_object_response_format_instruction_reaches_model_prompt() {
13865 let request = chat_request(json!({
13866 "response_format": {"type": "json_object"}
13867 }));
13868
13869 let internal = convert_chat_request(&request).expect("convert json_object");
13870 assert!(
13871 internal
13872 .prompt
13873 .contains("response_format requires a single valid JSON object"),
13874 "response_format instruction should reach the model prompt: {}",
13875 internal.prompt
13876 );
13877 assert!(
13878 internal.prompt.contains("Output only JSON"),
13879 "JSON-only instruction should reach the model prompt: {}",
13880 internal.prompt
13881 );
13882 assert_eq!(
13883 internal.sampling_params.response_format,
13884 ferrum_types::ResponseFormat::JsonObject,
13885 "json_object must reach the tokenizer-aware hard decoder"
13886 );
13887 assert_eq!(
13888 internal.sampling_params.structured_output_start,
13889 StructuredOutputStart::Immediate
13890 );
13891 }
13892
13893 fn harmony_json_template() -> ModelChatTemplate {
13894 let mut template = ModelChatTemplate::new(
13895 "{% for message in messages %}<|start|>{{ message.role }}<|message|>{{ message.content }}<|end|>{% endfor %}{% if add_generation_prompt %}<|start|>assistant{% endif %}",
13896 "harmony-structured-template",
13897 );
13898 template.output_protocol = ModelOutputProtocol::HarmonyGptOss;
13899 template
13900 }
13901
13902 #[test]
13903 fn harmony_structured_format_activates_at_final_payload() {
13904 let template = harmony_json_template();
13905 for (response_format, constrained) in [
13906 (json!({"type": "json_object"}), true),
13907 (
13908 json!({
13909 "type": "json_schema",
13910 "json_schema": {
13911 "name": "answer",
13912 "strict": true,
13913 "schema": {
13914 "type": "object",
13915 "properties": {"answer": {"type": "integer"}},
13916 "required": ["answer"],
13917 "additionalProperties": false
13918 }
13919 }
13920 }),
13921 true,
13922 ),
13923 (
13924 json!({
13925 "type": "json_schema",
13926 "json_schema": {
13927 "name": "best_effort",
13928 "strict": false,
13929 "schema": {"type": "object"}
13930 }
13931 }),
13932 false,
13933 ),
13934 (json!({"type": "text"}), false),
13935 ] {
13936 let request = chat_request(json!({"response_format": response_format}));
13937 let internal =
13938 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
13939 .unwrap();
13940 assert_eq!(
13941 internal.sampling_params.structured_output_start,
13942 if constrained {
13943 StructuredOutputStart::HarmonyFinal
13944 } else {
13945 StructuredOutputStart::Immediate
13946 }
13947 );
13948 assert_eq!(
13949 internal.sampling_params.response_completion_boundary,
13950 ResponseCompletionBoundary::Immediate,
13951 "Harmony framing must not be gated on a Text reasoning delimiter"
13952 );
13953 internal.sampling_params.validate().unwrap();
13954 }
13955 }
13956
13957 fn harmony_json_request(stream: bool) -> Value {
13958 json!({
13959 "model": "stub-model",
13960 "messages": [{"role": "user", "content": "Return an answer object."}],
13961 "stream": stream,
13962 "response_format": {
13963 "type": "json_schema",
13964 "json_schema": {
13965 "name": "answer",
13966 "strict": true,
13967 "schema": {
13968 "type": "object",
13969 "properties": {"answer": {"type": "integer"}},
13970 "required": ["answer"],
13971 "additionalProperties": false
13972 }
13973 }
13974 }
13975 })
13976 }
13977
13978 #[tokio::test]
13979 async fn harmony_strict_json_routes_validate_final_payload_in_sync_and_sse() {
13980 for (chunks, finish_reason, reasoning) in [
13981 (
13982 vec![
13983 "<|channel|>fi",
13984 "nal<|message|>{\"answer\":",
13985 "42}<|return|>",
13986 ],
13987 FinishReason::EOS,
13988 "",
13989 ),
13990 (
13991 vec![
13992 "<|channel|>analysis<|message|>Compute.",
13993 "<|end|><|start|>assistant<|channel|>fi",
13994 "nal<|message|>{\"answer\":42}<|return|>",
13995 ],
13996 FinishReason::EOS,
13997 "Compute.",
13998 ),
13999 (
14000 vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
14001 FinishReason::Length,
14002 "",
14003 ),
14004 (
14005 vec!["<|channel|>final<|message|>{\"answer\":", "42}"],
14006 FinishReason::Stop,
14007 "",
14008 ),
14009 ] {
14010 for stream in [false, true] {
14011 let engine = StubLlm {
14012 finish_reason,
14013 ..StubLlm::with_stream_chunks(&chunks)
14014 };
14015 let router = AxumServer::from_llm(Arc::new(engine))
14016 .with_prompt_template(Some(harmony_json_template()))
14017 .build_router();
14018 let mut request = harmony_json_request(stream);
14019 if finish_reason == FinishReason::Stop {
14020 request["stop"] = json!(["<|return|>"]);
14023 }
14024 let response = post_json(router, "/v1/chat/completions", request).await;
14025 assert_eq!(response.status(), AxumStatusCode::OK);
14026 if stream {
14027 let body = response_text(response).await;
14028 assert!(body.contains("data: [DONE]"));
14029 let events = responses_sse_json_events(&body);
14030 assert!(events.iter().all(|event| event.get("error").is_none()));
14031 let content: String = events
14032 .iter()
14033 .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
14034 .collect();
14035 let actual_reasoning: String = events
14036 .iter()
14037 .filter_map(|event| event["choices"][0]["delta"]["reasoning"].as_str())
14038 .collect();
14039 assert_eq!(
14040 serde_json::from_str::<Value>(&content).unwrap(),
14041 json!({"answer": 42})
14042 );
14043 assert_eq!(actual_reasoning, reasoning);
14044 } else {
14045 let body = response_json(response).await;
14046 let message = &body["choices"][0]["message"];
14047 assert_eq!(message["content"], "{\"answer\":42}");
14048 assert_eq!(message["reasoning"].as_str().unwrap_or(""), reasoning);
14049 }
14050 }
14051 }
14052 }
14053
14054 #[tokio::test]
14055 async fn harmony_strict_json_routes_reject_bad_framing_and_payload_without_sse_leaks() {
14056 for output in [
14057 "{\"answer\":42}",
14058 "<|channel|>final<|message|>{\"answer\":42}",
14059 "<|channel|>final<|message|>{\"answer\":42}<|call|>",
14060 "<|channel|>analysis<|message|>Compute.<|end|>\
14061 <|start|>assistant<|channel|>final<|message|>{\"answer\":\"wrong\"}<|return|>",
14062 ] {
14063 for stream in [false, true] {
14064 let response = post_json(
14065 router_with_stub_and_template(output, harmony_json_template()),
14066 "/v1/chat/completions",
14067 harmony_json_request(stream),
14068 )
14069 .await;
14070 if stream {
14071 assert_eq!(response.status(), AxumStatusCode::OK);
14072 let body = response_text(response).await;
14073 assert!(body.contains("data: [DONE]"));
14074 let events = responses_sse_json_events(&body);
14075 assert!(events.iter().any(|event| event.get("error").is_some()));
14076 for event in events {
14077 for field in ["content", "reasoning"] {
14078 assert!(event["choices"][0]["delta"][field]
14079 .as_str()
14080 .unwrap_or("")
14081 .is_empty());
14082 }
14083 }
14084 } else {
14085 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
14086 let body = response_json(response).await;
14087 assert_eq!(body["error"]["type"], "internal_server_error");
14088 assert!(body.get("choices").is_none());
14089 }
14090 }
14091 }
14092 }
14093
14094 #[test]
14095 fn json_object_thinking_template_activates_after_typed_end_delimiter() {
14096 let request = chat_request(json!({
14097 "response_format": {"type": "json_object"}
14098 }));
14099 let template = ModelChatTemplate::new(
14100 "{% for message in messages %}[{{ message.role }}]{{ message.content }}{% endfor %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14101 "thinking-test-template",
14102 );
14103
14104 assert_eq!(
14105 template.reasoning_protocol,
14106 ModelReasoningProtocol::PromptOpened
14107 );
14108 let internal =
14109 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14110 .expect("convert thinking json_object");
14111
14112 assert!(internal.prompt.ends_with("<assistant><think>\n"));
14113 assert!(internal
14114 .prompt
14115 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
14116 assert!(
14117 !internal.prompt.contains(THINK_END_TAG),
14118 "the instruction must not echo the typed end delimiter: {}",
14119 internal.prompt
14120 );
14121 assert_eq!(
14122 internal.sampling_params.structured_output_start,
14123 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14124 );
14125 assert_eq!(
14126 internal.sampling_params.response_completion_boundary,
14127 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14128 delimiter: THINK_END_TAG.to_string(),
14129 alternate_envelope: None,
14130 }
14131 );
14132 }
14133
14134 #[test]
14135 fn json_object_model_generated_thinking_activates_after_typed_end_delimiter() {
14136 let request = chat_request(json!({
14137 "response_format": {"type": "json_object"},
14138 "chat_template_kwargs": {"enable_thinking": true}
14139 }));
14140 let template = ModelChatTemplate::new(
14141 "{% 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 %}",
14142 "qwen3-model-generated-thinking-template",
14143 );
14144
14145 let internal =
14146 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14147 .expect("convert model-generated thinking json_object");
14148
14149 assert!(!has_unclosed_thinking_block(&internal.prompt));
14150 assert!(internal.prompt.ends_with("<assistant>"));
14151 assert!(internal
14152 .prompt
14153 .contains("Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text"));
14154 assert!(
14155 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
14156 "the instruction must not teach the model the typed reasoning delimiter: {}",
14157 internal.prompt
14158 );
14159 assert_eq!(
14160 internal.sampling_params.structured_output_start,
14161 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14162 );
14163 assert_eq!(
14164 internal.sampling_params.response_completion_boundary,
14165 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14166 delimiter: THINK_END_TAG.to_string(),
14167 alternate_envelope: None,
14168 }
14169 );
14170 }
14171
14172 #[test]
14173 fn strict_schema_model_generated_thinking_does_not_echo_typed_delimiter() {
14174 let request = chat_request(json!({
14175 "response_format": {
14176 "type": "json_schema",
14177 "json_schema": {
14178 "name": "reasoning_result",
14179 "strict": true,
14180 "schema": {
14181 "type": "object",
14182 "properties": {
14183 "result": {"type": "string", "const": "G00-c21-schema-OK"}
14184 },
14185 "required": ["result"],
14186 "additionalProperties": false
14187 }
14188 }
14189 },
14190 "chat_template_kwargs": {"enable_thinking": true}
14191 }));
14192 let template = ModelChatTemplate::new(
14193 "{% 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 %}",
14194 "qwen3-model-generated-thinking-template",
14195 );
14196
14197 let internal =
14198 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14199 .expect("convert model-generated thinking strict schema");
14200
14201 assert!(!has_unclosed_thinking_block(&internal.prompt));
14202 assert!(internal.prompt.ends_with("<assistant>"));
14203 assert!(internal.prompt.contains("G00-c21-schema-OK"));
14204 assert!(
14205 !internal.prompt.contains(THINK_START_TAG) && !internal.prompt.contains(THINK_END_TAG),
14206 "the instruction must not teach the model the typed reasoning delimiter: {}",
14207 internal.prompt
14208 );
14209 assert_eq!(
14210 internal.sampling_params.structured_output_start,
14211 StructuredOutputStart::AfterDelimiter(THINK_END_TAG.to_string())
14212 );
14213 assert_eq!(
14214 internal.sampling_params.response_completion_boundary,
14215 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14216 delimiter: THINK_END_TAG.to_string(),
14217 alternate_envelope: None,
14218 }
14219 );
14220 }
14221
14222 #[test]
14223 fn json_object_model_generated_thinking_hard_off_starts_immediately() {
14224 let request = chat_request(json!({
14225 "response_format": {"type": "json_object"},
14226 "chat_template_kwargs": {"enable_thinking": false}
14227 }));
14228 let template = ModelChatTemplate::new(
14229 "{% 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 %}",
14230 "qwen3-model-generated-thinking-template",
14231 );
14232
14233 let internal =
14234 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14235 .expect("convert disabled model-generated thinking json_object");
14236
14237 assert_eq!(
14238 internal.sampling_params.structured_output_start,
14239 StructuredOutputStart::Immediate
14240 );
14241 assert_eq!(
14242 internal.sampling_params.response_completion_boundary,
14243 ResponseCompletionBoundary::Immediate
14244 );
14245 assert!(internal.prompt.contains("no chain-of-thought"));
14246 }
14247
14248 #[test]
14249 fn response_completion_contract_is_set_on_text_thinking_template() {
14250 let request = chat_request(json!({}));
14251 let template = ModelChatTemplate::new(
14252 "{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14253 "thinking-test-template",
14254 );
14255
14256 let internal =
14257 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14258 .expect("convert thinking text request");
14259
14260 assert_eq!(
14261 internal.sampling_params.structured_output_start,
14262 StructuredOutputStart::Immediate
14263 );
14264 assert_eq!(
14265 internal.sampling_params.response_completion_boundary,
14266 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14267 delimiter: THINK_END_TAG.to_string(),
14268 alternate_envelope: None,
14269 }
14270 );
14271 }
14272
14273 #[test]
14274 fn thinking_tool_request_compiles_typed_envelope_into_completion_contract() {
14275 let request = chat_request(json!({
14276 "tools": [{
14277 "type": "function",
14278 "function": {
14279 "name": "weather",
14280 "parameters": {
14281 "type": "object",
14282 "properties": {"city": {"type": "string"}},
14283 "required": ["city"]
14284 }
14285 }
14286 }]
14287 }));
14288 let template = ModelChatTemplate::new(
14289 "{% if tools %}<tool_call><function=name><parameter=key>value</parameter></function></tool_call>{% endif %}{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
14290 "thinking-tool-template",
14291 );
14292
14293 let internal =
14294 convert_chat_request_with_template_model(&request, "stub-model", Some(&template))
14295 .expect("convert thinking tool request");
14296
14297 assert_eq!(
14298 internal.sampling_params.response_completion_boundary,
14299 ResponseCompletionBoundary::AfterDelimiterAndPayload {
14300 delimiter: THINK_END_TAG.to_string(),
14301 alternate_envelope: Some(ferrum_types::ResponseCompletionEnvelope {
14302 open_token_text: "<tool_call>".to_string(),
14303 close_token_text: "</tool_call>".to_string(),
14304 max_envelopes: 32,
14305 }),
14306 }
14307 );
14308 }
14309
14310 #[test]
14311 fn strict_json_schema_response_format_uses_guided_sampling_mode() {
14312 let request = chat_request(json!({
14313 "response_format": {
14314 "type": "json_schema",
14315 "json_schema": {
14316 "name": "answer",
14317 "strict": true,
14318 "schema": {
14319 "type": "object",
14320 "properties": {"answer": {"type": "string"}},
14321 "required": ["answer"]
14322 }
14323 }
14324 }
14325 }));
14326
14327 let internal = convert_chat_request(&request).expect("convert strict json_schema");
14328 assert!(
14329 internal
14330 .prompt
14331 .contains("response_format requires a single valid JSON value"),
14332 "response_format instruction should reach the model prompt: {}",
14333 internal.prompt
14334 );
14335 let ferrum_types::ResponseFormat::JsonSchema(schema) =
14336 internal.sampling_params.response_format
14337 else {
14338 panic!(
14339 "strict json_schema must reach guided decoding, got {:?}",
14340 internal.sampling_params.response_format
14341 );
14342 };
14343 let schema: serde_json::Value = serde_json::from_str(&schema).unwrap();
14344 assert_eq!(schema["type"], "object");
14345 assert_eq!(schema["properties"]["answer"]["type"], "string");
14346 assert_eq!(schema["required"], json!(["answer"]));
14347 }
14348
14349 #[tokio::test]
14350 async fn strict_json_schema_validates_non_streaming_response() {
14351 let request = chat_request(json!({
14352 "response_format": {
14353 "type": "json_schema",
14354 "json_schema": {
14355 "name": "answer",
14356 "strict": true,
14357 "schema": {
14358 "type": "object",
14359 "properties": {"answer": {"type": "string"}},
14360 "required": ["answer"]
14361 }
14362 }
14363 }
14364 }));
14365 let response = chat_completions_handler(
14366 State(state_with_stub("{\"answer\":\"yes\"}")),
14367 HeaderMap::new(),
14368 Ok(Json(request)),
14369 )
14370 .await
14371 .expect("strict response");
14372 assert_eq!(response.status(), AxumStatusCode::OK);
14373 let body = response_json(response).await;
14374 assert_eq!(
14375 body["choices"][0]["message"]["content"],
14376 "{\"answer\":\"yes\"}"
14377 );
14378 }
14379
14380 #[tokio::test]
14381 async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
14382 let request = chat_request(json!({
14383 "response_format": {
14384 "type": "json_schema",
14385 "json_schema": {
14386 "name": "answer",
14387 "strict": true,
14388 "schema": {
14389 "type": "object",
14390 "properties": {"answer": {"type": "string"}},
14391 "required": ["answer"]
14392 }
14393 }
14394 }
14395 }));
14396 let response = chat_completions_handler(
14397 State(state_with_stub(
14398 "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
14399 )),
14400 HeaderMap::new(),
14401 Ok(Json(request)),
14402 )
14403 .await
14404 .expect("strict response with reasoning");
14405 assert_eq!(response.status(), AxumStatusCode::OK);
14406 let body = response_json(response).await;
14407 assert_eq!(
14408 body["choices"][0]["message"]["content"],
14409 "{\"answer\":\"yes\"}"
14410 );
14411 assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
14412 }
14413
14414 #[tokio::test]
14415 async fn strict_json_schema_validates_streaming_final_response() {
14416 let response = post_json(
14417 router_with_stub("{\"answer\":\"yes\"}"),
14418 "/v1/chat/completions",
14419 json!({
14420 "model": "stub-model",
14421 "messages": [{"role": "user", "content": "Return an answer object."}],
14422 "stream": true,
14423 "response_format": {
14424 "type": "json_schema",
14425 "json_schema": {
14426 "name": "answer",
14427 "strict": true,
14428 "schema": {
14429 "type": "object",
14430 "properties": {"answer": {"type": "string"}},
14431 "required": ["answer"]
14432 }
14433 }
14434 }
14435 }),
14436 )
14437 .await;
14438 assert_eq!(response.status(), AxumStatusCode::OK);
14439 let body = response_text(response).await;
14440 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14441 assert!(
14442 body.contains("\\\"answer\\\":\\\"yes\\\""),
14443 "strict streaming content missing: {body}"
14444 );
14445 assert!(
14446 !body.contains("\"error\""),
14447 "valid strict streaming response should not emit error: {body}"
14448 );
14449 }
14450
14451 #[tokio::test]
14452 async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
14453 let response = post_json(
14454 router_with_stub_stream_chunks(&[
14455 "<think>\nreasoning",
14456 "\n</think>\n\n",
14457 "{\"answer\":\"yes\"}",
14458 ]),
14459 "/v1/chat/completions",
14460 json!({
14461 "model": "stub-model",
14462 "messages": [{"role": "user", "content": "Return an answer object."}],
14463 "stream": true,
14464 "response_format": {
14465 "type": "json_schema",
14466 "json_schema": {
14467 "name": "answer",
14468 "strict": true,
14469 "schema": {
14470 "type": "object",
14471 "properties": {"answer": {"type": "string"}},
14472 "required": ["answer"]
14473 }
14474 }
14475 }
14476 }),
14477 )
14478 .await;
14479 assert_eq!(response.status(), AxumStatusCode::OK);
14480 let body = response_text(response).await;
14481 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14482 assert!(
14483 body.contains("\\\"answer\\\":\\\"yes\\\""),
14484 "strict streaming content missing: {body}"
14485 );
14486 assert!(
14487 body.contains(r#""reasoning":"\nreasoning\n""#),
14488 "strict streaming should keep reasoning separate: {body}"
14489 );
14490 assert!(
14491 !body.contains("\"error\""),
14492 "valid strict streaming response should not emit error: {body}"
14493 );
14494 }
14495
14496 #[tokio::test]
14497 async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
14498 let response = post_json(
14499 router_with_stub("not json"),
14500 "/v1/chat/completions",
14501 json!({
14502 "model": "stub-model",
14503 "messages": [{"role": "user", "content": "Return an answer object."}],
14504 "stream": true,
14505 "response_format": {
14506 "type": "json_schema",
14507 "json_schema": {
14508 "name": "answer",
14509 "strict": true,
14510 "schema": {
14511 "type": "object",
14512 "properties": {"answer": {"type": "string"}},
14513 "required": ["answer"]
14514 }
14515 }
14516 }
14517 }),
14518 )
14519 .await;
14520 assert_eq!(response.status(), AxumStatusCode::OK);
14521 let body = response_text(response).await;
14522 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
14523 assert!(
14524 body.contains("\"type\":\"internal_server_error\""),
14525 "strict streaming validation failure should emit OpenAI error: {body}"
14526 );
14527 assert!(
14528 body.contains("\"param\":\"response_format.json_schema\""),
14529 "strict streaming validation error should identify schema param: {body}"
14530 );
14531 assert!(
14532 body.contains("invalid JSON"),
14533 "strict streaming validation should report invalid JSON: {body}"
14534 );
14535 assert!(
14536 !body.contains("not json"),
14537 "strict streaming must not emit invalid partial deltas before validation failure: {body}"
14538 );
14539 }
14540
14541 #[tokio::test]
14542 async fn route_strict_json_schema_supported_schema_passes_100_runs() {
14543 let request_body = json!({
14544 "model": "stub-model",
14545 "messages": [{"role": "user", "content": "Return an answer object."}],
14546 "response_format": {
14547 "type": "json_schema",
14548 "json_schema": {
14549 "name": "answer",
14550 "strict": true,
14551 "schema": {
14552 "type": "object",
14553 "properties": {"answer": {"type": "string"}},
14554 "required": ["answer"]
14555 }
14556 }
14557 }
14558 });
14559 let router = router_with_stub("{\"answer\":\"yes\"}");
14560 for run in 0..100 {
14561 let response =
14562 post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
14563 assert_eq!(
14564 response.status(),
14565 AxumStatusCode::OK,
14566 "strict schema run {run} returned non-200"
14567 );
14568 let body = response_json(response).await;
14569 let content = body["choices"][0]["message"]["content"]
14570 .as_str()
14571 .unwrap_or("");
14572 assert_eq!(
14573 content, "{\"answer\":\"yes\"}",
14574 "strict schema run {run} returned unexpected content"
14575 );
14576 let parsed: serde_json::Value =
14577 serde_json::from_str(content).expect("strict content JSON");
14578 assert_eq!(parsed["answer"], "yes");
14579 }
14580 }
14581
14582 #[test]
14583 fn server_prefix_prompt_observation_follows_engine_authority() {
14584 let policy = CachePolicy {
14585 prefix_cache_enabled: true,
14586 session_cache_mode: "off".to_string(),
14587 session_cache_max_entries: 128,
14588 session_cache_max_tokens: 4096,
14589 };
14590 for authority in [
14591 ExecutionResourceAuthority::PlanRuntime,
14592 ExecutionResourceAuthority::LegacyEngine,
14593 ] {
14594 let engine = StubLlm {
14595 resource_authority: authority,
14596 ..StubLlm::new("ok")
14597 };
14598 assert!(engine.cache_metrics_snapshot().is_none());
14601 let state = AppState::default().with_llm(Arc::new(engine));
14602 state.record_prefix_prompt("alpha beta gamma", &policy);
14603 state.record_prefix_prompt("alpha beta delta", &policy);
14604 let stats = state.cache.stats();
14605 let prompts = state.cache.prefix_prompts.lock().unwrap();
14606 match authority {
14607 ExecutionResourceAuthority::PlanRuntime => {
14608 assert!(prompts.is_empty());
14609 assert_eq!(stats.prefix_entries, 0);
14610 assert_eq!(stats.prefix_bytes, 0);
14611 assert_eq!(stats.prefix_hits, 0);
14612 assert_eq!(stats.prefix_misses, 0);
14613 assert_eq!(stats.prefix_saved_prefill_tokens, 0);
14614 }
14615 ExecutionResourceAuthority::LegacyEngine => {
14616 assert_eq!(prompts.len(), 2);
14617 assert!(prompts.contains_key("alpha beta gamma"));
14618 assert!(prompts.contains_key("alpha beta delta"));
14619 assert_eq!(stats.prefix_entries, 2);
14620 assert_eq!(stats.prefix_bytes, 32);
14621 assert_eq!(stats.prefix_hits, 1);
14622 assert_eq!(stats.prefix_misses, 1);
14623 assert!(stats.prefix_saved_prefill_tokens > 0);
14624 }
14625 }
14626 }
14627 }
14628
14629 #[test]
14630 fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
14631 let cache = CacheRuntimeState::default();
14632 let policy = CachePolicy {
14633 prefix_cache_enabled: true,
14634 session_cache_mode: "memory".to_string(),
14635 session_cache_max_entries: 128,
14636 session_cache_max_tokens: 4096,
14637 };
14638 cache.record_prefix_prompt("alpha beta gamma", &policy);
14639 cache.record_prefix_prompt("alpha beta delta", &policy);
14640
14641 let engine_snapshot = json!({
14642 "position": "real-kv-reuse",
14643 "source": "llama-family-paged-block-prefix-cache",
14644 "enabled": true,
14645 "hits": 7,
14646 "misses": 3,
14647 "evictions": 1,
14648 "saved_prefill_tokens": 64,
14649 "entries": 5,
14650 "bytes": 8192,
14651 "block_size": 16,
14652 "kv_dtype": "fp16",
14653 "selected_pipeline_mode": "batch",
14654 "selected_stage_bridge": "host",
14655 "stage_count": 2,
14656 });
14657
14658 let health = cache.health_json(&policy, Some(&engine_snapshot));
14659 let prefix = &health["prefix_cache"];
14660 assert_eq!(prefix["position"], "real-kv-reuse");
14661 assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
14662 assert_eq!(prefix["hits"], 7);
14663 assert_eq!(prefix["misses"], 3);
14664 assert_eq!(prefix["evictions"], 1);
14665 assert_eq!(prefix["saved_prefill_tokens"], 64);
14666 assert_eq!(prefix["entries"], 5);
14667 assert_eq!(prefix["bytes"], 8192);
14668 assert_eq!(prefix["block_size"], 16);
14669 assert_eq!(prefix["kv_dtype"], "fp16");
14670 assert_eq!(prefix["selected_pipeline_mode"], "batch");
14671 assert_eq!(prefix["selected_stage_bridge"], "host");
14672 assert_eq!(prefix["stage_count"], 2);
14673
14674 let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
14675 assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
14676 assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
14677 assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
14678 assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
14679 assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
14680 }
14681
14682 #[tokio::test]
14683 async fn strict_json_schema_invalid_model_output_fails_before_response() {
14684 let request = chat_request(json!({
14685 "response_format": {
14686 "type": "json_schema",
14687 "json_schema": {
14688 "name": "answer",
14689 "strict": true,
14690 "schema": {
14691 "type": "object",
14692 "properties": {"answer": {"type": "string"}},
14693 "required": ["answer"]
14694 }
14695 }
14696 }
14697 }));
14698 let err = chat_completions_handler(
14699 State(state_with_stub("not json")),
14700 HeaderMap::new(),
14701 Ok(Json(request)),
14702 )
14703 .await
14704 .expect_err("invalid strict response should fail");
14705 let (status, body) = error_json(err).await;
14706 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14707 assert_eq!(body["error"]["type"], "internal_server_error");
14708 assert!(body["error"]["message"]
14709 .as_str()
14710 .unwrap()
14711 .contains("json_schema.strict"));
14712 }
14713
14714 #[tokio::test]
14715 async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
14716 let request = chat_request(json!({
14717 "response_format": {
14718 "type": "json_schema",
14719 "json_schema": {
14720 "name": "answer",
14721 "strict": true,
14722 "schema": {
14723 "type": "object",
14724 "properties": {"answer": {"type": "string"}},
14725 "required": ["answer"]
14726 }
14727 }
14728 }
14729 }));
14730 let err = chat_completions_handler(
14731 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
14732 HeaderMap::new(),
14733 Ok(Json(request)),
14734 )
14735 .await
14736 .expect_err("strict schema should fail fenced JSON instead of repairing it");
14737 let (status, body) = error_json(err).await;
14738 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
14739 assert_eq!(body["error"]["type"], "internal_server_error");
14740 assert!(body["error"]["message"]
14741 .as_str()
14742 .unwrap()
14743 .contains("json_schema.strict: invalid JSON"));
14744 }
14745}