1use crate::{
7 chat_template::{
8 render_chat_prompt_with_model_template_options,
9 render_chat_prompt_with_tools_and_model_template, ChatTemplateOptions, ModelChatTemplate,
10 },
11 openai::*,
12 traits::HttpServer,
13 types::*,
14};
15use async_trait::async_trait;
16use axum::{
17 extract::{multipart::MultipartRejection, rejection::JsonRejection, State},
18 http::{HeaderMap, StatusCode as AxumStatusCode},
19 response::{sse::Event, IntoResponse, Response, Sse},
20 routing::{get, post},
21 Json, Router,
22};
23use ferrum_interfaces::engine::{EmbedEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine};
24use ferrum_types::{
25 EngineMetrics, EngineStatus, FerrumConfigBuilder, FerrumError as Error, FinishReason,
26 InferenceRequest, InferenceResponse, ModelId, Priority, RequestId, ResolvedFerrumConfig,
27 RuntimeConfigSnapshot, SamplingParams, TokenUsage, DEFAULT_MAX_TOKENS_METADATA_KEY,
28};
29use std::{
30 collections::HashMap,
31 sync::{Arc, Mutex},
32};
33use tokio::sync::mpsc;
34use tokio_stream::StreamExt;
35use tower::ServiceBuilder;
36use tower_http::{cors::CorsLayer, trace::TraceLayer};
37use tracing::{debug, error, info, span, warn, Level};
38use uuid::Uuid;
39
40const DEFAULT_SAMPLING_TEMPERATURE: f32 = 0.0;
41const DEFAULT_SAMPLING_TOP_P: f32 = 1.0;
42const DEFAULT_COMPLETION_MAX_TOKENS: u32 = 512;
43const INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
44const THINK_START_TAG: &str = "<think>";
45const THINK_END_TAG: &str = "</think>";
46const DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH: u64 = 128;
47const INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS: &[&str] =
48 &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", "</s>"];
49const FERRUM_SESSION_HEADER: &str = "x-ferrum-session";
50
51#[derive(Debug, Clone)]
52struct CachePolicy {
53 prefix_cache_enabled: bool,
54 session_cache_mode: String,
55 session_cache_max_entries: usize,
56 session_cache_max_tokens: usize,
57}
58
59impl CachePolicy {
60 fn current() -> Self {
61 Self {
62 prefix_cache_enabled: env_bool("FERRUM_PREFIX_CACHE_PRODUCT")
63 .or_else(|| env_bool("FERRUM_PREFIX_CACHE_REQUESTED"))
64 .or_else(|| env_bool("FERRUM_PREFIX_CACHE"))
65 .unwrap_or(false),
66 session_cache_mode: std::env::var("FERRUM_SESSION_CACHE")
67 .unwrap_or_else(|_| "off".to_string())
68 .to_ascii_lowercase(),
69 session_cache_max_entries: env_usize("FERRUM_SESSION_CACHE_MAX_ENTRIES").unwrap_or(128),
70 session_cache_max_tokens: env_usize("FERRUM_SESSION_CACHE_MAX_TOKENS").unwrap_or(4096),
71 }
72 }
73
74 fn session_memory_enabled(&self) -> bool {
75 self.session_cache_mode == "memory"
76 }
77}
78
79fn env_bool(key: &str) -> Option<bool> {
80 match std::env::var(key).ok()?.to_ascii_lowercase().as_str() {
81 "1" | "true" | "yes" | "on" => Some(true),
82 "0" | "false" | "no" | "off" => Some(false),
83 _ => None,
84 }
85}
86
87fn env_usize(key: &str) -> Option<usize> {
88 std::env::var(key).ok()?.parse().ok()
89}
90
91static PROM_HANDLE: std::sync::OnceLock<metrics_exporter_prometheus::PrometheusHandle> =
93 std::sync::OnceLock::new();
94
95pub fn init_prometheus_recorder() {
100 PROM_HANDLE.get_or_init(|| {
101 let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
102 let handle = builder
103 .install_recorder()
104 .expect("Failed to install Prometheus recorder");
105 info!("Prometheus metrics recorder installed");
106 handle
107 });
108}
109
110pub struct AxumServer {
116 state: AppState,
117 config: ServerConfig,
118}
119
120impl AxumServer {
121 pub fn from_state(state: AppState) -> Self {
123 Self {
124 state,
125 config: ServerConfig::default(),
126 }
127 }
128
129 pub fn from_llm(engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
131 Self::from_state(AppState::default().with_llm(engine))
132 }
133
134 pub fn from_embed(engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
136 Self::from_state(AppState::default().with_embed(engine))
137 }
138
139 pub fn from_transcribe(engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
142 Self::from_state(AppState::default().with_transcribe(engine))
143 }
144
145 pub fn from_tts(engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
147 Self::from_state(AppState::default().with_tts(engine))
148 }
149
150 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
154 self.state = self.state.with_auto_config(auto_config);
155 self
156 }
157
158 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
161 self.state = self.state.with_prompt_template(prompt_template);
162 self
163 }
164
165 pub fn with_lora_adapters(
167 mut self,
168 base_model_id: impl Into<String>,
169 adapters: Vec<LoraAdapterModel>,
170 ) -> Self {
171 self.state = self.state.with_lora_adapters(base_model_id, adapters);
172 self
173 }
174
175 fn build_router(&self) -> Router {
177 let app_state = self.state.clone();
178
179 Router::new()
180 .route("/v1/chat/completions", post(chat_completions_handler))
182 .route("/v1/completions", post(completions_handler))
183 .route("/v1/embeddings", post(embeddings_handler))
184 .route("/v1/audio/transcriptions", post(transcriptions_handler))
185 .route("/v1/audio/speech", post(speech_handler))
186 .route("/v1/models", get(models_handler))
187 .route("/health", get(health_handler))
189 .route("/metrics", get(metrics_handler))
190 .route("/", get(root_handler))
191 .layer(
193 ServiceBuilder::new()
194 .layer(TraceLayer::new_for_http())
195 .layer(CorsLayer::permissive()), )
197 .with_state(app_state)
198 }
199}
200
201#[derive(Clone, Default)]
205pub struct AppState {
206 pub llm: Option<Arc<dyn LlmInferenceEngine + Send + Sync>>,
207 pub embed: Option<Arc<dyn EmbedEngine + Send + Sync>>,
208 pub transcribe: Option<Arc<dyn TranscribeEngine + Send + Sync>>,
209 pub tts: Option<Arc<dyn TtsEngine + Send + Sync>>,
210 pub auto_config: Option<ResolvedFerrumConfig>,
211 pub prompt_template: Option<Arc<ModelChatTemplate>>,
212 pub lora_registry: Arc<LoraModelRegistry>,
213 cache: Arc<CacheRuntimeState>,
214}
215
216#[derive(Clone, Debug, PartialEq, Eq)]
217pub struct LoraAdapterModel {
218 pub name: String,
219 pub model_id: String,
220 pub path: String,
221}
222
223impl LoraAdapterModel {
224 pub fn new(
225 name: impl Into<String>,
226 model_id: impl Into<String>,
227 path: impl Into<String>,
228 ) -> Self {
229 Self {
230 name: name.into(),
231 model_id: model_id.into(),
232 path: path.into(),
233 }
234 }
235}
236
237#[derive(Clone, Debug, Default)]
238pub struct LoraModelRegistry {
239 base_model_id: Option<String>,
240 adapters: Vec<LoraAdapterModel>,
241}
242
243#[derive(Clone, Debug)]
244struct LoraModelResolution {
245 base_model_id: String,
246 adapter: Option<LoraAdapterModel>,
247}
248
249impl LoraModelRegistry {
250 pub fn new(base_model_id: impl Into<String>, adapters: Vec<LoraAdapterModel>) -> Self {
251 Self {
252 base_model_id: Some(base_model_id.into()),
253 adapters,
254 }
255 }
256
257 pub fn is_enabled(&self) -> bool {
258 !self.adapters.is_empty()
259 }
260
261 fn adapter_models(&self) -> &[LoraAdapterModel] {
262 &self.adapters
263 }
264
265 fn resolve(
266 &self,
267 request_model: &str,
268 loaded_models: &[ModelId],
269 ) -> std::result::Result<Option<LoraModelResolution>, ServerError> {
270 if !self.is_enabled() {
271 return Ok(None);
272 }
273 let base = self
274 .base_model_id
275 .clone()
276 .or_else(|| loaded_models.first().map(ToString::to_string))
277 .unwrap_or_default();
278 if request_model == base || loaded_models.iter().any(|model| model.0 == request_model) {
279 return Ok(Some(LoraModelResolution {
280 base_model_id: request_model.to_string(),
281 adapter: None,
282 }));
283 }
284 if let Some(adapter) = self
285 .adapters
286 .iter()
287 .find(|adapter| adapter.model_id == request_model)
288 {
289 return Ok(Some(LoraModelResolution {
290 base_model_id: base,
291 adapter: Some(adapter.clone()),
292 }));
293 }
294 Err(ServerError::invalid_request(
295 format!("unknown LoRA adapter model: {request_model}"),
296 Some("model"),
297 ))
298 }
299}
300
301impl AppState {
302 pub fn with_llm(mut self, engine: Arc<dyn LlmInferenceEngine + Send + Sync>) -> Self {
303 self.llm = Some(engine);
304 self
305 }
306 pub fn with_embed(mut self, engine: Arc<dyn EmbedEngine + Send + Sync>) -> Self {
307 self.embed = Some(engine);
308 self
309 }
310 pub fn with_transcribe(mut self, engine: Arc<dyn TranscribeEngine + Send + Sync>) -> Self {
311 self.transcribe = Some(engine);
312 self
313 }
314 pub fn with_tts(mut self, engine: Arc<dyn TtsEngine + Send + Sync>) -> Self {
315 self.tts = Some(engine);
316 self
317 }
318
319 pub fn with_auto_config(mut self, auto_config: ResolvedFerrumConfig) -> Self {
320 self.auto_config = Some(auto_config);
321 self
322 }
323
324 pub fn with_prompt_template(mut self, prompt_template: Option<ModelChatTemplate>) -> Self {
325 self.prompt_template = prompt_template.map(Arc::new);
326 self
327 }
328
329 pub fn with_lora_adapters(
330 mut self,
331 base_model_id: impl Into<String>,
332 adapters: Vec<LoraAdapterModel>,
333 ) -> Self {
334 self.lora_registry = Arc::new(LoraModelRegistry::new(base_model_id, adapters));
335 self
336 }
337
338 async fn status(&self) -> EngineStatus {
341 if let Some(e) = &self.llm {
342 return e.status().await;
343 }
344 if let Some(e) = &self.embed {
345 return e.status().await;
346 }
347 if let Some(e) = &self.transcribe {
348 return e.status().await;
349 }
350 if let Some(e) = &self.tts {
351 return e.status().await;
352 }
353 EngineStatus {
354 is_ready: false,
355 loaded_models: vec![],
356 active_requests: 0,
357 queued_requests: 0,
358 memory_usage: ferrum_types::MemoryUsage {
359 total_bytes: 0,
360 used_bytes: 0,
361 free_bytes: 0,
362 gpu_memory_bytes: None,
363 cpu_memory_bytes: None,
364 cache_memory_bytes: 0,
365 utilization_percent: 0.0,
366 },
367 uptime_seconds: 0,
368 last_heartbeat: chrono::Utc::now(),
369 version: env!("CARGO_PKG_VERSION").to_string(),
370 }
371 }
372
373 fn metrics(&self) -> EngineMetrics {
374 if let Some(e) = &self.llm {
375 return e.metrics();
376 }
377 if let Some(e) = &self.embed {
378 return e.metrics();
379 }
380 if let Some(e) = &self.transcribe {
381 return e.metrics();
382 }
383 if let Some(e) = &self.tts {
384 return e.metrics();
385 }
386 EngineMetrics {
387 total_requests: 0,
388 successful_requests: 0,
389 failed_requests: 0,
390 avg_request_latency_ms: 0.0,
391 p95_request_latency_ms: 0.0,
392 p99_request_latency_ms: 0.0,
393 throughput_rps: 0.0,
394 tokens_per_second: 0.0,
395 queue_metrics: Default::default(),
396 resource_utilization: Default::default(),
397 error_stats: Default::default(),
398 performance_breakdown: Default::default(),
399 }
400 }
401}
402
403#[derive(Default)]
404struct CacheRuntimeState {
405 stats: Mutex<CacheStats>,
406 prefix_prompts: Mutex<HashMap<String, usize>>,
407 sessions: Mutex<HashMap<String, Vec<ChatMessage>>>,
408}
409
410#[derive(Debug, Clone, Default)]
411struct CacheStats {
412 prefix_hits: u64,
413 prefix_misses: u64,
414 prefix_evictions: u64,
415 prefix_saved_prefill_tokens: u64,
416 prefix_entries: u64,
417 prefix_bytes: u64,
418 session_hits: u64,
419 session_misses: u64,
420 session_evictions: u64,
421 session_entries: u64,
422 session_tokens: u64,
423}
424
425#[derive(Clone)]
426struct SessionContext {
427 id: String,
428 prior_messages: Vec<ChatMessage>,
429 incoming_messages: Vec<ChatMessage>,
430}
431
432impl CacheRuntimeState {
433 fn record_prefix_prompt(&self, prompt: &str, policy: &CachePolicy) {
434 if !policy.prefix_cache_enabled {
435 return;
436 }
437
438 let prompt_tokens = approx_tokens(prompt);
439 let mut prompts = self.prefix_prompts.lock().expect("prefix cache lock");
440 let saved_tokens = prompts
441 .keys()
442 .map(|seen| approx_tokens_for_chars(longest_common_prefix_chars(seen, prompt)))
443 .max()
444 .unwrap_or(0);
445
446 let mut stats = self.stats.lock().expect("cache stats lock");
447 if saved_tokens > 0 {
448 stats.prefix_hits += 1;
449 stats.prefix_saved_prefill_tokens += saved_tokens as u64;
450 } else {
451 stats.prefix_misses += 1;
452 }
453
454 let max_entries = policy.session_cache_max_entries.max(1);
455 if !prompts.contains_key(prompt) && prompts.len() >= max_entries {
456 if let Some(key) = prompts.keys().next().cloned() {
457 prompts.remove(&key);
458 stats.prefix_evictions += 1;
459 }
460 }
461 prompts.insert(prompt.to_string(), prompt_tokens);
462 stats.prefix_entries = prompts.len() as u64;
463 stats.prefix_bytes = prompts.keys().map(|key| key.len() as u64).sum();
464 }
465
466 fn prepare_session_request(
467 &self,
468 request: &mut ChatCompletionsRequest,
469 headers: &HeaderMap,
470 policy: &CachePolicy,
471 ) -> Option<SessionContext> {
472 let session_id = request_session_id(headers, request)?;
473 if !policy.session_memory_enabled() {
474 return None;
475 }
476
477 let incoming_messages = request.messages.clone();
478 let prior_messages = {
479 let sessions = self.sessions.lock().expect("session cache lock");
480 sessions.get(&session_id).cloned().unwrap_or_default()
481 };
482 {
483 let mut stats = self.stats.lock().expect("cache stats lock");
484 if prior_messages.is_empty() {
485 stats.session_misses += 1;
486 } else {
487 stats.session_hits += 1;
488 let mut merged = prior_messages.clone();
489 merged.extend(request.messages.clone());
490 request.messages = merged;
491 }
492 }
493
494 Some(SessionContext {
495 id: session_id,
496 prior_messages,
497 incoming_messages,
498 })
499 }
500
501 fn update_session(
502 &self,
503 context: Option<SessionContext>,
504 assistant_message: ChatMessage,
505 policy: &CachePolicy,
506 ) {
507 let Some(context) = context else {
508 return;
509 };
510 if !policy.session_memory_enabled() {
511 return;
512 }
513
514 let mut history = context.prior_messages;
515 history.extend(context.incoming_messages);
516 history.push(assistant_message);
517 trim_messages_to_token_budget(&mut history, policy.session_cache_max_tokens);
518
519 let mut sessions = self.sessions.lock().expect("session cache lock");
520 if !sessions.contains_key(&context.id)
521 && sessions.len() >= policy.session_cache_max_entries.max(1)
522 {
523 if let Some(evict_key) = sessions.keys().next().cloned() {
524 sessions.remove(&evict_key);
525 self.stats
526 .lock()
527 .expect("cache stats lock")
528 .session_evictions += 1;
529 }
530 }
531 sessions.insert(context.id, history);
532
533 let entries = sessions.len() as u64;
534 let tokens = sessions
535 .values()
536 .map(|messages| {
537 messages
538 .iter()
539 .map(|msg| approx_tokens(&msg.content))
540 .sum::<usize>()
541 })
542 .sum::<usize>() as u64;
543 let mut stats = self.stats.lock().expect("cache stats lock");
544 stats.session_entries = entries;
545 stats.session_tokens = tokens;
546 }
547
548 fn stats(&self) -> CacheStats {
549 let mut stats = self.stats.lock().expect("cache stats lock").clone();
550 stats.prefix_entries = self.prefix_prompts.lock().expect("prefix cache lock").len() as u64;
551 let sessions = self.sessions.lock().expect("session cache lock");
552 stats.session_entries = sessions.len() as u64;
553 stats.session_tokens = sessions
554 .values()
555 .map(|messages| {
556 messages
557 .iter()
558 .map(|msg| approx_tokens(&msg.content))
559 .sum::<usize>()
560 })
561 .sum::<usize>() as u64;
562 stats
563 }
564
565 fn health_json(
566 &self,
567 policy: &CachePolicy,
568 engine_prefix_cache: Option<&serde_json::Value>,
569 ) -> serde_json::Value {
570 let stats = self.stats();
571 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
572 let prefix_misses =
573 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
574 let prefix_evictions =
575 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
576 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
577 .unwrap_or(stats.prefix_saved_prefill_tokens);
578 let prefix_entries =
579 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
580 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
581 let mut prefix_cache = serde_json::json!({
582 "enabled": engine_bool(engine_prefix_cache, "enabled").unwrap_or(policy.prefix_cache_enabled),
583 "position": engine_str(engine_prefix_cache, "position").unwrap_or("product-observability"),
584 "source": engine_str(engine_prefix_cache, "source").unwrap_or("server-prompt-lcp-observability"),
585 "entries": prefix_entries,
586 "hits": prefix_hits,
587 "misses": prefix_misses,
588 "evictions": prefix_evictions,
589 "saved_prefill_tokens": prefix_saved,
590 "bytes": prefix_bytes,
591 "block_size": engine_u64(engine_prefix_cache, "block_size"),
592 "kv_dtype": engine_str(engine_prefix_cache, "kv_dtype"),
593 });
594 if let (Some(engine), Some(prefix)) = (
595 engine_prefix_cache.and_then(|value| value.as_object()),
596 prefix_cache.as_object_mut(),
597 ) {
598 for (key, value) in engine {
599 prefix.entry(key.clone()).or_insert_with(|| value.clone());
600 }
601 }
602 serde_json::json!({
603 "prefix_cache": prefix_cache,
604 "session_cache": {
605 "mode": policy.session_cache_mode,
606 "entries": stats.session_entries,
607 "hits": stats.session_hits,
608 "misses": stats.session_misses,
609 "evictions": stats.session_evictions,
610 "tokens": stats.session_tokens,
611 "max_entries": policy.session_cache_max_entries,
612 "max_tokens": policy.session_cache_max_tokens,
613 }
614 })
615 }
616
617 fn prometheus_metrics(&self, engine_prefix_cache: Option<&serde_json::Value>) -> String {
618 let stats = self.stats();
619 let prefix_hits = engine_u64(engine_prefix_cache, "hits").unwrap_or(stats.prefix_hits);
620 let prefix_misses =
621 engine_u64(engine_prefix_cache, "misses").unwrap_or(stats.prefix_misses);
622 let prefix_evictions =
623 engine_u64(engine_prefix_cache, "evictions").unwrap_or(stats.prefix_evictions);
624 let prefix_saved = engine_u64(engine_prefix_cache, "saved_prefill_tokens")
625 .unwrap_or(stats.prefix_saved_prefill_tokens);
626 let prefix_entries =
627 engine_u64(engine_prefix_cache, "entries").unwrap_or(stats.prefix_entries);
628 let prefix_bytes = engine_u64(engine_prefix_cache, "bytes").unwrap_or(stats.prefix_bytes);
629 format!(
630 concat!(
631 "ferrum_prefix_cache_hits_total {}\n",
632 "ferrum_prefix_cache_misses_total {}\n",
633 "ferrum_prefix_cache_evictions_total {}\n",
634 "ferrum_prefix_cache_saved_prefill_tokens_total {}\n",
635 "ferrum_prefix_cache_entries {}\n",
636 "ferrum_prefix_cache_bytes {}\n",
637 "ferrum_session_cache_hits_total {}\n",
638 "ferrum_session_cache_misses_total {}\n",
639 "ferrum_session_cache_evictions_total {}\n",
640 "ferrum_session_cache_entries {}\n",
641 "ferrum_session_cache_tokens {}\n"
642 ),
643 prefix_hits,
644 prefix_misses,
645 prefix_evictions,
646 prefix_saved,
647 prefix_entries,
648 prefix_bytes,
649 stats.session_hits,
650 stats.session_misses,
651 stats.session_evictions,
652 stats.session_entries,
653 stats.session_tokens,
654 )
655 }
656}
657
658fn engine_u64(snapshot: Option<&serde_json::Value>, key: &str) -> Option<u64> {
659 snapshot?.get(key)?.as_u64()
660}
661
662fn engine_bool(snapshot: Option<&serde_json::Value>, key: &str) -> Option<bool> {
663 snapshot?.get(key)?.as_bool()
664}
665
666fn engine_str<'a>(snapshot: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
667 snapshot?.get(key)?.as_str()
668}
669
670fn auto_config_health_value(auto_config: Option<&ResolvedFerrumConfig>) -> serde_json::Value {
671 match auto_config {
672 Some(auto_config) => auto_config.effective_config_document(),
673 None => {
674 match FerrumConfigBuilder::new(RuntimeConfigSnapshot::capture_current()).resolve() {
675 Ok(auto_config) => auto_config.effective_config_document(),
676 Err(err) => serde_json::json!({
677 "schema_version": 1,
678 "error": err.to_string(),
679 }),
680 }
681 }
682 }
683}
684
685fn admission_health_json(
686 engine_status: &EngineStatus,
687 scheduler_metrics: &EngineMetrics,
688 auto_config: &serde_json::Value,
689) -> serde_json::Value {
690 let configured = auto_config
691 .get("admission")
692 .and_then(|value| value.as_object());
693 let effective_max_concurrent = configured
694 .and_then(|value| value.get("effective_max_concurrent"))
695 .and_then(|value| value.as_u64())
696 .unwrap_or_else(|| {
697 (engine_status.active_requests + engine_status.queued_requests)
698 .max(1)
699 .try_into()
700 .unwrap_or(u64::MAX)
701 });
702 serde_json::json!({
703 "schema_version": 1,
704 "source": "startup_auto_config_and_engine_status",
705 "effective_max_concurrent": effective_max_concurrent,
706 "queue_depth": engine_status.queued_requests as u64,
707 "active_prefill": 0u64,
708 "active_decode": engine_status.active_requests as u64,
709 "current_batch_size": engine_status.active_requests as u64,
710 "rejected_requests_total": 0u64,
711 "failed_requests_total": scheduler_metrics.failed_requests,
712 "completed_requests_total": scheduler_metrics.successful_requests,
713 "avg_queue_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
714 "scheduler_policy": configured
715 .and_then(|value| value.get("scheduler_policy"))
716 .and_then(|value| value.as_str())
717 .unwrap_or("unknown"),
718 "phase_detail_source": "engine_status_does_not_split_prefill_decode",
719 })
720}
721
722fn admission_prometheus_metrics(admission: &serde_json::Value) -> String {
723 let value = |key: &str| {
724 admission
725 .get(key)
726 .and_then(|value| value.as_u64())
727 .unwrap_or(0)
728 };
729 format!(
730 concat!(
731 "ferrum_admission_effective_max_concurrent {}\n",
732 "ferrum_admission_queue_depth {}\n",
733 "ferrum_admission_active_prefill {}\n",
734 "ferrum_admission_active_decode {}\n",
735 "ferrum_admission_current_batch_size {}\n",
736 "ferrum_admission_rejected_requests_total {}\n",
737 "ferrum_admission_failed_requests_total {}\n",
738 "ferrum_admission_completed_requests_total {}\n"
739 ),
740 value("effective_max_concurrent"),
741 value("queue_depth"),
742 value("active_prefill"),
743 value("active_decode"),
744 value("current_batch_size"),
745 value("rejected_requests_total"),
746 value("failed_requests_total"),
747 value("completed_requests_total"),
748 )
749}
750
751fn request_session_id(headers: &HeaderMap, request: &ChatCompletionsRequest) -> Option<String> {
752 headers
753 .get(FERRUM_SESSION_HEADER)
754 .and_then(|value| value.to_str().ok())
755 .map(str::trim)
756 .filter(|value| !value.is_empty())
757 .map(str::to_string)
758 .or_else(|| {
759 request
760 .metadata
761 .as_ref()
762 .and_then(|metadata| metadata.get("ferrum_session_id"))
763 .and_then(|value| value.as_str())
764 .map(str::trim)
765 .filter(|value| !value.is_empty())
766 .map(str::to_string)
767 })
768}
769
770fn approx_tokens(text: &str) -> usize {
771 approx_tokens_for_chars(text.chars().count())
772}
773
774fn approx_tokens_for_chars(chars: usize) -> usize {
775 (chars / 4).max(1)
776}
777
778fn longest_common_prefix_chars(a: &str, b: &str) -> usize {
779 a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
780}
781
782fn trim_messages_to_token_budget(messages: &mut Vec<ChatMessage>, max_tokens: usize) {
783 let max_tokens = max_tokens.max(1);
784 while messages.len() > 1
785 && messages
786 .iter()
787 .map(|msg| approx_tokens(&msg.content))
788 .sum::<usize>()
789 > max_tokens
790 {
791 messages.remove(0);
792 }
793}
794
795#[async_trait]
796impl HttpServer for AxumServer {
797 async fn start(&self, config: &ServerConfig) -> ferrum_types::Result<()> {
798 let addr = format!("{}:{}", config.host, config.port);
799 info!("Starting Axum server on {}", addr);
800
801 let app = self.build_router();
802 let listener = tokio::net::TcpListener::bind(&addr)
803 .await
804 .map_err(|e| Error::internal(format!("Failed to bind to {}: {}", addr, e)))?;
805
806 info!("Server listening on {}", addr);
807
808 axum::serve(listener, app)
809 .await
810 .map_err(|e| Error::internal(format!("Server error: {}", e)))?;
811
812 Ok(())
813 }
814
815 async fn stop(&self, _timeout: std::time::Duration) -> ferrum_types::Result<()> {
816 info!("Stopping Axum server");
817 Ok(())
819 }
820
821 fn is_running(&self) -> bool {
822 true
824 }
825
826 fn address(&self) -> Option<std::net::SocketAddr> {
827 format!("{}:{}", self.config.host, self.config.port)
829 .parse()
830 .ok()
831 }
832
833 fn register_handler(
834 &mut self,
835 _path: &str,
836 _method: HttpMethod,
837 _handler: Box<dyn crate::traits::RequestHandler>,
838 ) {
839 unimplemented!("Dynamic handler registration not implemented in MVP")
841 }
842
843 fn register_middleware(&mut self, _middleware: Box<dyn crate::traits::Middleware>) {
844 unimplemented!("Dynamic middleware registration not implemented in MVP")
846 }
847
848 fn get_metrics(&self) -> ServerMetrics {
849 ServerMetrics {
851 total_requests: 0,
852 requests_by_endpoint: std::collections::HashMap::new(),
853 requests_by_status: std::collections::HashMap::new(),
854 avg_response_time_ms: 0.0,
855 p95_response_time_ms: 0.0,
856 p99_response_time_ms: 0.0,
857 active_connections: 0,
858 bytes_sent: 0,
859 bytes_received: 0,
860 error_rate: 0.0,
861 uptime_seconds: 0,
862 }
863 }
864
865 async fn health_check(&self) -> HealthStatus {
866 HealthStatus::Healthy
867 }
868}
869
870async fn chat_completions_handler(
872 State(state): State<AppState>,
873 headers: HeaderMap,
874 request: std::result::Result<Json<ChatCompletionsRequest>, JsonRejection>,
875) -> std::result::Result<Response, ServerError> {
876 let Json(mut request) = request.map_err(|e| {
877 ServerError::invalid_request(format!("invalid chat completions request: {e}"), None)
878 })?;
879 let cache_policy = CachePolicy::current();
880 let session_context =
881 state
882 .cache
883 .prepare_session_request(&mut request, &headers, &cache_policy);
884
885 let span = span!(Level::INFO, "chat_completions", model = %request.model);
886 let _enter = span.enter();
887
888 info!(
889 "Received chat completions request for model: {}",
890 request.model
891 );
892 debug!("Request: {:?}", request);
893
894 validate_chat_request(&request)?;
897 let loaded_models = state.status().await.loaded_models;
898 let lora_resolution = state
899 .lora_registry
900 .resolve(&request.model, &loaded_models)?;
901
902 let template_model_id = lora_resolution
904 .as_ref()
905 .map(|resolution| resolution.base_model_id.clone())
906 .or_else(|| loaded_models.first().map(ToString::to_string))
907 .unwrap_or_else(|| request.model.clone());
908 let mut inference_request = convert_chat_request_with_template_model(
909 &request,
910 &template_model_id,
911 state.prompt_template.as_deref(),
912 )
913 .map_err(server_error_from_ferrum_error)?;
914 apply_lora_resolution(&mut inference_request, lora_resolution.as_ref());
915 state
916 .cache
917 .record_prefix_prompt(&inference_request.prompt, &cache_policy);
918
919 if request.stream.unwrap_or(false) {
921 handle_chat_completions_stream(state, request, inference_request).await
922 } else {
923 handle_chat_completions_sync(state, request, inference_request, session_context).await
924 }
925}
926
927async fn handle_chat_completions_stream(
929 state: AppState,
930 openai_request: ChatCompletionsRequest,
931 inference_request: InferenceRequest,
932) -> std::result::Result<Response, ServerError> {
933 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
934
935 let engine = state.llm.clone().ok_or_else(|| {
937 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
938 })?;
939 let request_id = Uuid::new_v4().to_string();
940 let include_stream_usage = openai_request
941 .stream_options
942 .as_ref()
943 .and_then(|opts| opts.include_usage)
944 .unwrap_or(false);
945 let buffer_json_object_stream = response_format_is_json_object(&openai_request);
946 let buffer_strict_json_schema_stream = strict_json_schema_string(&openai_request)?.is_some();
947 let stream_api_request = match inference_request.api_request.as_ref() {
948 Some(ferrum_types::ApiRequest::Chat(request)) => request.clone(),
949 _ => api_chat_request(&openai_request, openai_request.tool_choice.as_ref()),
950 };
951 let buffer_structured_api_stream =
952 ferrum_types::chat_api_may_emit_tool_or_function_call(&stream_api_request);
953 let buffer_stream_output = buffer_json_object_stream
954 || buffer_strict_json_schema_stream
955 || buffer_structured_api_stream;
956 let mut stream = match engine.infer_stream(inference_request).await {
957 Ok(stream) => stream,
958 Err(e) => {
959 error!("Stream generation failed before first chunk: {}", e);
960 let _ = tx.send(Ok(openai_error_sse_event(
961 e.to_string(),
962 "internal_server_error",
963 None,
964 )));
965 let _ = tx.send(Ok(Event::default().data("[DONE]")));
966 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
967 return Ok(Sse::new(stream).into_response());
968 }
969 };
970
971 tokio::spawn(async move {
972 let mut current_text = String::new();
973 let mut sent_reasoning_len = 0usize;
974 let mut sent_content_len = 0usize;
975
976 while let Some(result) = stream.next().await {
977 match result {
978 Ok(chunk) => {
979 if !chunk.text.is_empty() {
980 current_text.push_str(&chunk.text);
981
982 if !buffer_stream_output {
983 if should_defer_reasoning_stream_delta(¤t_text) {
984 continue;
985 }
986 let parsed = parse_reasoning_response(¤t_text);
987 let full_reasoning = parsed.reasoning.as_deref().unwrap_or("");
988 let reasoning_delta =
989 stream_text_delta(full_reasoning, &mut sent_reasoning_len);
990 let content_delta =
991 stream_text_delta(&parsed.content, &mut sent_content_len);
992 if reasoning_delta.is_empty() && content_delta.is_empty() {
993 continue;
994 }
995 let response_chunk = ChatCompletionsResponse {
997 id: request_id.clone(),
998 object: "chat.completion.chunk".to_string(),
999 created: chrono::Utc::now().timestamp() as u64,
1000 model: openai_request.model.clone(),
1001 choices: vec![ChatChoice {
1002 index: 0,
1003 message: None,
1004 delta: Some(ChatMessage {
1005 role: MessageRole::Assistant,
1006 content: content_delta,
1007 reasoning: (!reasoning_delta.is_empty())
1008 .then_some(reasoning_delta),
1009 name: None,
1010 tool_calls: None,
1011 tool_call_id: None,
1012 function_call: None,
1013 }),
1014 finish_reason: None,
1015 }],
1016 usage: None,
1017 };
1018
1019 let sse_event = Event::default()
1020 .json_data(&response_chunk)
1021 .unwrap_or_else(|_| Event::default().data("error"));
1022 if tx.send(Ok(sse_event)).is_err() {
1023 break;
1024 }
1025 }
1026 }
1027
1028 if chunk.finish_reason.is_some() {
1029 let usage = chunk.usage.as_ref().map(openai_usage_from_token_usage);
1030 let mut parsed_final = parse_reasoning_response(¤t_text);
1031 parsed_final.content = normalize_structured_response_content(
1032 &openai_request,
1033 &parsed_final.content,
1034 );
1035 if let Err(e) = validate_strict_json_schema_response(
1036 &openai_request,
1037 &parsed_final.content,
1038 ) {
1039 let error_event = openai_error_sse_event(
1040 strict_stream_validation_error_message(e),
1041 "internal_server_error",
1042 Some("response_format.json_schema"),
1043 );
1044 let _ = tx.send(Ok(error_event));
1045 let _ = tx.send(Ok(Event::default().data("[DONE]")));
1046 break;
1047 }
1048 let structured_chat_response = match chunk.api_response.as_ref() {
1049 Some(ferrum_types::ApiResponse::Chat(response)) => {
1050 Some(response.clone())
1051 }
1052 _ if buffer_structured_api_stream => {
1053 chat_api_response_from_parsed_generated_text(
1054 &stream_api_request,
1055 &parsed_final,
1056 )
1057 }
1058 _ => None,
1059 };
1060
1061 if let Some(chat_response) = structured_chat_response.as_ref() {
1062 let mut delta = openai_chat_delta_from_api(&chat_response.message);
1063 if delta.reasoning.is_none() {
1064 delta.reasoning = parsed_final.reasoning.clone();
1065 }
1066 let response_chunk = ChatCompletionsResponse {
1067 id: request_id.clone(),
1068 object: "chat.completion.chunk".to_string(),
1069 created: chrono::Utc::now().timestamp() as u64,
1070 model: openai_request.model.clone(),
1071 choices: vec![ChatChoice {
1072 index: 0,
1073 message: None,
1074 delta: Some(delta),
1075 finish_reason: None,
1076 }],
1077 usage: None,
1078 };
1079
1080 let sse_event = Event::default()
1081 .json_data(&response_chunk)
1082 .unwrap_or_else(|_| Event::default().data("error"));
1083 if tx.send(Ok(sse_event)).is_err() {
1084 break;
1085 }
1086 } else if tool_choice_required(&openai_request) {
1087 log_required_tool_choice_failure(
1088 &openai_request,
1089 &parsed_final.content,
1090 parsed_final.reasoning.as_deref(),
1091 );
1092 let error_event = openai_error_sse_event(
1093 "model output did not satisfy required tool_choice",
1094 "invalid_request_error",
1095 Some("tool_choice"),
1096 );
1097 let _ = tx.send(Ok(error_event));
1098 let _ = tx.send(Ok(Event::default().data("[DONE]")));
1099 break;
1100 } else if buffer_structured_api_stream
1101 && parsed_final.content.trim().is_empty()
1102 {
1103 let error_event = openai_error_sse_event(
1104 "model output did not satisfy tool/function call request",
1105 "internal_server_error",
1106 Some("tool_choice"),
1107 );
1108 let _ = tx.send(Ok(error_event));
1109 let _ = tx.send(Ok(Event::default().data("[DONE]")));
1110 break;
1111 } else if buffer_stream_output && !current_text.is_empty() {
1112 let response_chunk = ChatCompletionsResponse {
1113 id: request_id.clone(),
1114 object: "chat.completion.chunk".to_string(),
1115 created: chrono::Utc::now().timestamp() as u64,
1116 model: openai_request.model.clone(),
1117 choices: vec![ChatChoice {
1118 index: 0,
1119 message: None,
1120 delta: Some(ChatMessage {
1121 role: MessageRole::Assistant,
1122 content: parsed_final.content.clone(),
1123 reasoning: parsed_final.reasoning.clone(),
1124 name: None,
1125 tool_calls: None,
1126 tool_call_id: None,
1127 function_call: None,
1128 }),
1129 finish_reason: None,
1130 }],
1131 usage: None,
1132 };
1133
1134 let sse_event = Event::default()
1135 .json_data(&response_chunk)
1136 .unwrap_or_else(|_| Event::default().data("error"));
1137 if tx.send(Ok(sse_event)).is_err() {
1138 break;
1139 }
1140 }
1141 let final_chunk = ChatCompletionsResponse {
1151 id: request_id.clone(),
1152 object: "chat.completion.chunk".to_string(),
1153 created: chrono::Utc::now().timestamp() as u64,
1154 model: openai_request.model.clone(),
1155 choices: vec![ChatChoice {
1156 index: 0,
1157 message: None,
1158 delta: Some(ChatMessage {
1159 role: MessageRole::Assistant,
1160 content: String::new(),
1161 reasoning: None,
1162 name: None,
1163 tool_calls: None,
1164 tool_call_id: None,
1165 function_call: None,
1166 }),
1167 finish_reason: structured_chat_response
1168 .as_ref()
1169 .and_then(|response| response.finish_reason.clone())
1170 .or_else(|| {
1171 chunk.finish_reason.as_ref().map(finish_reason_to_string)
1172 })
1173 .or(Some("length".to_string())),
1174 }],
1175 usage: None,
1176 };
1177
1178 let final_event = Event::default()
1179 .json_data(&final_chunk)
1180 .unwrap_or_else(|_| Event::default().data("error"));
1181 let _ = tx.send(Ok(final_event));
1182 if include_stream_usage && usage.is_some() {
1183 let usage_chunk = ChatCompletionsResponse {
1184 id: request_id.clone(),
1185 object: "chat.completion.chunk".to_string(),
1186 created: chrono::Utc::now().timestamp() as u64,
1187 model: openai_request.model.clone(),
1188 choices: vec![],
1189 usage,
1190 };
1191 let usage_event = Event::default()
1192 .json_data(&usage_chunk)
1193 .unwrap_or_else(|_| Event::default().data("error"));
1194 let _ = tx.send(Ok(usage_event));
1195 }
1196 let _ = tx.send(Ok(Event::default().data("[DONE]")));
1197 break;
1198 }
1199 }
1200 Err(e) => {
1201 error!("Stream generation error: {}", e);
1202 let _ = tx.send(Ok(openai_error_sse_event(
1203 e.to_string(),
1204 "internal_server_error",
1205 None,
1206 )));
1207 let _ = tx.send(Ok(Event::default().data("[DONE]")));
1208 break;
1209 }
1210 }
1211 }
1212 });
1213
1214 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1215 let sse_stream = Sse::new(stream);
1216
1217 Ok(sse_stream.into_response())
1218}
1219
1220async fn handle_chat_completions_sync(
1222 state: AppState,
1223 openai_request: ChatCompletionsRequest,
1224 inference_request: InferenceRequest,
1225 session_context: Option<SessionContext>,
1226) -> std::result::Result<Response, ServerError> {
1227 info!("Processing non-streaming chat completion");
1228
1229 let engine = state.llm.clone().ok_or_else(|| {
1230 ServerError::ServiceUnavailable("LLM engine not loaded; chat unavailable".into())
1231 })?;
1232 let request_chat_api = inference_request
1233 .api_request
1234 .as_ref()
1235 .and_then(|api_request| match api_request {
1236 ferrum_types::ApiRequest::Chat(chat_request) => {
1237 ferrum_types::chat_api_may_emit_tool_or_function_call(chat_request)
1238 .then(|| chat_request.clone())
1239 }
1240 _ => None,
1241 });
1242 match engine.infer(inference_request).await {
1243 Ok(output) => {
1244 let InferenceResponse {
1245 text: output_text,
1246 finish_reason,
1247 usage,
1248 api_response,
1249 ..
1250 } = output;
1251
1252 let after_fence = match &openai_request.response_format {
1264 Some(rf) if rf.format_type == "json_object" => {
1265 strip_markdown_json_fence(&output_text)
1266 }
1267 _ => output_text,
1268 };
1269 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
1270 let content = strip_after_stop(&after_fence, &stop_sequences);
1271 let parsed = parse_reasoning_response(&content);
1272 let visible_content =
1273 normalize_structured_response_content(&openai_request, &parsed.content);
1274 let mut message = ChatMessage {
1275 role: MessageRole::Assistant,
1276 content: visible_content,
1277 reasoning: parsed.reasoning.clone(),
1278 name: None,
1279 tool_calls: None,
1280 tool_call_id: None,
1281 function_call: None,
1282 };
1283 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
1284 let structured_chat_response = match api_response.as_ref() {
1285 Some(ferrum_types::ApiResponse::Chat(chat_response)) => Some(chat_response.clone()),
1286 _ => match request_chat_api.as_ref() {
1287 Some(chat_request) => {
1288 chat_api_response_from_parsed_generated_text(chat_request, &parsed)
1289 }
1290 _ => None,
1291 },
1292 };
1293 if let Some(chat_response) = structured_chat_response.as_ref() {
1294 message = openai_chat_message_from_api(&chat_response.message);
1295 if message.reasoning.is_none() {
1296 message.reasoning = parsed.reasoning.clone();
1297 }
1298 if let Some(reason) = &chat_response.finish_reason {
1299 openai_finish_reason = reason.clone();
1300 }
1301 } else if tool_choice_required(&openai_request) {
1302 log_required_tool_choice_failure(
1303 &openai_request,
1304 &parsed.content,
1305 parsed.reasoning.as_deref(),
1306 );
1307 return Err(ServerError::invalid_request(
1308 "model output did not satisfy required tool_choice",
1309 Some("tool_choice"),
1310 ));
1311 }
1312 validate_strict_json_schema_response(&openai_request, &message.content)?;
1313 state
1314 .cache
1315 .update_session(session_context, message.clone(), &CachePolicy::current());
1316 let response = ChatCompletionsResponse {
1317 id: Uuid::new_v4().to_string(),
1318 object: "chat.completion".to_string(),
1319 created: chrono::Utc::now().timestamp() as u64,
1320 model: openai_request.model,
1321 choices: vec![ChatChoice {
1322 index: 0,
1323 message: Some(message),
1324 delta: None,
1325 finish_reason: Some(openai_finish_reason),
1326 }],
1327 usage: Some(openai_usage_from_token_usage(&usage)),
1328 };
1329
1330 Ok(Json(response).into_response())
1331 }
1332 Err(e) => {
1333 error!("Generation failed: {}", e);
1334 Err(server_error_from_ferrum_error(e))
1335 }
1336 }
1337}
1338
1339#[allow(dead_code)]
1341fn convert_chat_request(
1342 request: &ChatCompletionsRequest,
1343) -> ferrum_types::Result<InferenceRequest> {
1344 convert_chat_request_with_template_model(request, &request.model, None)
1345}
1346
1347fn convert_chat_request_with_template_model(
1354 request: &ChatCompletionsRequest,
1355 template_model_id: &str,
1356 model_template: Option<&ModelChatTemplate>,
1357) -> ferrum_types::Result<InferenceRequest> {
1358 let no_tools: &[ChatTool] = &[];
1359 let tools = if tool_choice_none_hides_tools(request.tool_choice.as_ref(), model_template) {
1360 no_tools
1361 } else {
1362 request.tools.as_deref().unwrap_or_default()
1363 };
1364 let default_tool_choice =
1365 default_auto_tool_choice_for_tools(tools, request.tool_choice.as_ref());
1366 let effective_tool_choice = request
1367 .tool_choice
1368 .as_ref()
1369 .or(default_tool_choice.as_ref());
1370 let functions = request.functions.as_deref().unwrap_or_default();
1371 let forced_response_format = forced_tool_choice_response_format(request);
1372 let render_messages =
1373 render_messages_with_response_format_instruction(request, forced_response_format.as_ref());
1374 let chat_template_options = chat_template_options_for_request(request, model_template)?;
1375 let prompt = if tools.is_empty() && functions.is_empty() {
1376 render_chat_prompt_with_model_template_options(
1377 &render_messages,
1378 template_model_id,
1379 model_template,
1380 &chat_template_options,
1381 )
1382 } else {
1383 render_chat_prompt_with_tools_and_model_template(
1384 &render_messages,
1385 template_model_id,
1386 model_template,
1387 &chat_template_options,
1388 tools,
1389 effective_tool_choice,
1390 functions,
1391 request.function_call.as_ref(),
1392 )
1393 };
1394 let api_chat = api_chat_request(request, effective_tool_choice);
1395 let may_emit_structured_call = ferrum_types::chat_api_may_emit_tool_or_function_call(&api_chat);
1396 let mut metadata = HashMap::new();
1397 metadata.insert(
1398 "openai_messages".to_string(),
1399 serde_json::to_value(&request.messages)?,
1400 );
1401 if let Some(tools) = &request.tools {
1402 metadata.insert("openai_tools".to_string(), serde_json::to_value(tools)?);
1403 }
1404 if let Some(tool_choice) = effective_tool_choice {
1405 metadata.insert(
1406 "openai_tool_choice".to_string(),
1407 serde_json::to_value(tool_choice)?,
1408 );
1409 }
1410 if let Some(functions) = &request.functions {
1411 metadata.insert(
1412 "openai_legacy_functions".to_string(),
1413 serde_json::to_value(functions)?,
1414 );
1415 }
1416 if let Some(function_call) = &request.function_call {
1417 metadata.insert(
1418 "openai_legacy_function_call".to_string(),
1419 serde_json::to_value(function_call)?,
1420 );
1421 }
1422 if request.ignore_eos.unwrap_or(false) {
1423 metadata.insert("ferrum_ignore_eos".to_string(), serde_json::json!(true));
1424 }
1425 if request.max_completion_tokens.is_none() && request.max_tokens.is_none() {
1426 metadata.insert(
1427 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
1428 serde_json::json!(true),
1429 );
1430 }
1431 if !has_unclosed_thinking_block(&prompt) {
1432 let mut forbidden = vec![THINK_END_TAG.to_string()];
1433 if may_emit_structured_call {
1434 for token_text in INITIAL_STRUCTURED_CALL_FORBIDDEN_TOKEN_TEXTS {
1435 push_unique_forbidden_token_text(&mut forbidden, token_text);
1436 }
1437 if let Some(eos) = model_template.as_ref().and_then(|template| {
1438 template
1439 .eos_token
1440 .as_deref()
1441 .filter(|token| !token.is_empty())
1442 }) {
1443 push_unique_forbidden_token_text(&mut forbidden, eos);
1444 }
1445 }
1446 if chat_template_options.enable_thinking == Some(false) {
1447 push_unique_forbidden_token_text(&mut forbidden, THINK_START_TAG);
1448 }
1449 metadata.insert(
1450 INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
1451 serde_json::json!(forbidden),
1452 );
1453 }
1454
1455 Ok(InferenceRequest {
1456 id: RequestId(Uuid::new_v4()),
1457 model_id: ModelId(request.model.clone()),
1458 prompt,
1459 sampling_params: SamplingParams {
1460 max_tokens: chat_completion_max_tokens(request) as usize,
1461 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
1462 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
1463 top_k: None, repetition_penalty: 1.0,
1465 presence_penalty: request.presence_penalty.unwrap_or(0.0),
1466 frequency_penalty: request.frequency_penalty.unwrap_or(0.0),
1467 stop_sequences: request.stop.clone().unwrap_or_default(),
1468 seed: request.seed,
1469 min_p: None,
1470 tfs: None,
1471 typical_p: None,
1472 mirostat: None,
1473 response_format: forced_response_format
1474 .or_else(|| inferred_auto_tool_response_format(request))
1475 .unwrap_or(ferrum_types::ResponseFormat::Text),
1476 },
1477 stream: request.stream.unwrap_or(false),
1478 priority: Priority::Normal, client_id: None,
1480 session_id: None,
1481 created_at: chrono::Utc::now(),
1482 api_request: Some(ferrum_types::ApiRequest::Chat(api_chat)),
1483 metadata,
1484 })
1485}
1486
1487fn push_unique_forbidden_token_text(tokens: &mut Vec<String>, token: &str) {
1488 if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
1489 tokens.push(token.to_string());
1490 }
1491}
1492
1493fn default_auto_tool_choice_for_tools(
1494 tools: &[ChatTool],
1495 choice: Option<&ToolChoice>,
1496) -> Option<ToolChoice> {
1497 if choice.is_none() && !tools.is_empty() {
1498 Some(ToolChoice::Mode("auto".to_string()))
1499 } else {
1500 None
1501 }
1502}
1503
1504fn tool_choice_none(choice: Option<&ToolChoice>) -> bool {
1505 matches!(choice, Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none"))
1506}
1507
1508fn tool_choice_none_hides_tools(
1509 choice: Option<&ToolChoice>,
1510 model_template: Option<&ModelChatTemplate>,
1511) -> bool {
1512 tool_choice_none(choice)
1513 && model_template
1514 .map(|template| template.template.contains("tools_in_user_message"))
1515 .unwrap_or(false)
1516}
1517
1518fn chat_template_options_for_request(
1519 request: &ChatCompletionsRequest,
1520 model_template: Option<&ModelChatTemplate>,
1521) -> ferrum_types::Result<ChatTemplateOptions> {
1522 let mut options = ChatTemplateOptions::default_for_template(model_template);
1523 let Some(kwargs) = request.chat_template_kwargs.as_ref() else {
1524 return Ok(options);
1525 };
1526 let Some(value) = kwargs.get("enable_thinking") else {
1527 return Ok(options);
1528 };
1529 let Some(enable_thinking) = value.as_bool() else {
1530 return Err(Error::invalid_request(
1531 "chat_template_kwargs.enable_thinking must be a boolean",
1532 ));
1533 };
1534 options.enable_thinking = Some(enable_thinking);
1535 Ok(options)
1536}
1537
1538fn render_messages_with_response_format_instruction(
1539 request: &ChatCompletionsRequest,
1540 forced_response_format: Option<&ferrum_types::ResponseFormat>,
1541) -> Vec<ChatMessage> {
1542 let Some(instruction) = response_format_prompt_instruction(request, forced_response_format)
1543 else {
1544 return request.messages.clone();
1545 };
1546 let mut messages = Vec::with_capacity(request.messages.len() + 1);
1547 messages.push(ChatMessage {
1548 role: MessageRole::System,
1549 content: instruction,
1550 reasoning: None,
1551 name: None,
1552 tool_calls: None,
1553 tool_call_id: None,
1554 function_call: None,
1555 });
1556 messages.extend(request.messages.clone());
1557 messages
1558}
1559
1560fn response_format_prompt_instruction(
1561 request: &ChatCompletionsRequest,
1562 _forced_response_format: Option<&ferrum_types::ResponseFormat>,
1563) -> Option<String> {
1564 if let Some(format) = request.response_format.as_ref() {
1565 return match format.format_type.as_str() {
1566 "json_object" => Some(
1567 "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."
1568 .to_string(),
1569 ),
1570 "json_schema" => {
1571 let schema = format.json_schema.as_ref()?.schema.as_ref()?;
1572 let schema_text = serde_json::to_string(schema).ok()?;
1573 Some(format!(
1574 "The response_format requires a single valid JSON object satisfying this JSON Schema. Output only JSON, with no markdown fences, no explanation, no chain-of-thought, and no extra text. Schema: {schema_text}"
1575 ))
1576 }
1577 _ => None,
1578 };
1579 }
1580 None
1581}
1582
1583fn forced_tool_choice_response_format(
1584 request: &ChatCompletionsRequest,
1585) -> Option<ferrum_types::ResponseFormat> {
1586 let selected_tool = selected_tool_for_forced_tool_choice(request)?;
1587 let schema = guided_tool_arguments_schema(selected_tool.function.parameters.as_ref())?;
1588 serde_json::to_string(&schema)
1589 .ok()
1590 .map(ferrum_types::ResponseFormat::JsonSchema)
1591}
1592
1593fn inferred_auto_tool_response_format(
1594 request: &ChatCompletionsRequest,
1595) -> Option<ferrum_types::ResponseFormat> {
1596 if !tool_choice_auto_or_omitted(request.tool_choice.as_ref()) {
1597 return None;
1598 }
1599 let tool = single_function_tool(request.tools.as_deref()?)?;
1600 let prompt = latest_user_text(request)?;
1601 if !text_mentions_tool(&prompt, &tool.function) {
1602 return None;
1603 }
1604 let schema = serde_json::to_string(&guided_tool_arguments_schema(
1605 tool.function.parameters.as_ref(),
1606 )?)
1607 .ok()?;
1608 Some(ferrum_types::ResponseFormat::JsonSchema(schema))
1609}
1610
1611fn selected_tool_for_forced_tool_choice(request: &ChatCompletionsRequest) -> Option<&ChatTool> {
1612 match request.tool_choice.as_ref()? {
1613 ToolChoice::Function {
1614 tool_type,
1615 function,
1616 } if tool_type == "function" => request
1617 .tools
1618 .as_ref()?
1619 .iter()
1620 .find(|tool| tool.function.name == function.name),
1621 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
1622 request.tools.as_ref()?.first()
1623 }
1624 _ => None,
1625 }
1626}
1627
1628fn guided_tool_arguments_schema(
1629 parameters: Option<&serde_json::Value>,
1630) -> Option<serde_json::Value> {
1631 let mut schema = parameters?.clone();
1632 bound_unconstrained_tool_argument_strings(
1633 &mut schema,
1634 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH,
1635 );
1636 Some(schema)
1637}
1638
1639fn bound_unconstrained_tool_argument_strings(value: &mut serde_json::Value, default_max: u64) {
1640 match value {
1641 serde_json::Value::Object(map) => {
1642 let is_string = map
1643 .get("type")
1644 .and_then(serde_json::Value::as_str)
1645 .is_some_and(|ty| ty == "string");
1646 let has_finite_string_shape = map.contains_key("enum") || map.contains_key("maxLength");
1647 if is_string && !has_finite_string_shape {
1648 map.insert(
1649 "maxLength".to_string(),
1650 serde_json::Value::Number(default_max.into()),
1651 );
1652 }
1653 if let Some(properties) = map
1654 .get_mut("properties")
1655 .and_then(serde_json::Value::as_object_mut)
1656 {
1657 for property in properties.values_mut() {
1658 bound_unconstrained_tool_argument_strings(property, default_max);
1659 }
1660 }
1661 if let Some(items) = map.get_mut("items") {
1662 bound_unconstrained_tool_argument_strings(items, default_max);
1663 }
1664 }
1665 serde_json::Value::Array(items) => {
1666 for item in items {
1667 bound_unconstrained_tool_argument_strings(item, default_max);
1668 }
1669 }
1670 _ => {}
1671 }
1672}
1673
1674fn tool_choice_auto_or_omitted(choice: Option<&ToolChoice>) -> bool {
1675 match choice {
1676 None => true,
1677 Some(ToolChoice::Mode(mode)) => mode.eq_ignore_ascii_case("auto"),
1678 _ => false,
1679 }
1680}
1681
1682fn single_function_tool(tools: &[ChatTool]) -> Option<&ChatTool> {
1683 let mut function_tools = tools.iter().filter(|tool| tool.tool_type == "function");
1684 let tool = function_tools.next()?;
1685 function_tools.next().is_none().then_some(tool)
1686}
1687
1688fn latest_user_text(request: &ChatCompletionsRequest) -> Option<String> {
1689 request
1690 .messages
1691 .iter()
1692 .rev()
1693 .find(|message| matches!(message.role, MessageRole::User))
1694 .map(|message| message.content.clone())
1695 .filter(|content| !content.trim().is_empty())
1696}
1697
1698fn text_mentions_tool(text: &str, function: &ChatFunction) -> bool {
1699 let text_lower = text.to_lowercase();
1700 for word in ascii_words(&function.name) {
1701 if text_lower.contains(&word) {
1702 return true;
1703 }
1704 }
1705 if let Some(description) = &function.description {
1706 for word in ascii_words(description) {
1707 if text_lower.contains(&word) {
1708 return true;
1709 }
1710 }
1711 for bigram in cjk_bigrams(description) {
1712 if text.contains(&bigram) {
1713 return true;
1714 }
1715 }
1716 }
1717 false
1718}
1719
1720fn ascii_words(text: &str) -> Vec<String> {
1721 text.split(|ch: char| !ch.is_ascii_alphanumeric())
1722 .filter_map(|word| {
1723 let word = word.to_ascii_lowercase();
1724 (word.len() >= 3).then_some(word)
1725 })
1726 .collect()
1727}
1728
1729fn cjk_bigrams(text: &str) -> Vec<String> {
1730 let chars = text
1731 .chars()
1732 .filter(|ch| matches!(*ch as u32, 0x3400..=0x9fff | 0xf900..=0xfaff))
1733 .collect::<Vec<_>>();
1734 chars
1735 .windows(2)
1736 .map(|window| window.iter().collect::<String>())
1737 .collect()
1738}
1739
1740fn has_unclosed_thinking_block(prompt: &str) -> bool {
1741 match (prompt.rfind(THINK_START_TAG), prompt.rfind(THINK_END_TAG)) {
1742 (Some(start), Some(end)) => start > end,
1743 (Some(_), None) => true,
1744 _ => false,
1745 }
1746}
1747
1748fn should_defer_reasoning_stream_delta(text: &str) -> bool {
1749 let candidate = text.trim_start_matches(['\r', '\n']);
1750 if candidate.is_empty() {
1751 return true;
1752 }
1753 THINK_START_TAG.starts_with(candidate) || THINK_END_TAG.starts_with(candidate)
1754}
1755
1756fn stream_text_delta(text: &str, sent_len: &mut usize) -> String {
1757 if *sent_len <= text.len() && text.is_char_boundary(*sent_len) {
1758 let delta = text[*sent_len..].to_string();
1759 *sent_len = text.len();
1760 return delta;
1761 }
1762 *sent_len = text.len();
1763 String::new()
1764}
1765
1766#[derive(Debug, Clone, PartialEq, Eq)]
1767struct ParsedReasoningResponse {
1768 content: String,
1769 reasoning: Option<String>,
1770}
1771
1772fn parse_reasoning_response(text: &str) -> ParsedReasoningResponse {
1773 let Some(start) = text.find(THINK_START_TAG) else {
1774 if let Some(end) = text.find(THINK_END_TAG) {
1775 let reasoning = text[..end].to_string();
1776 let content = text[end + THINK_END_TAG.len()..]
1777 .trim_start_matches(['\r', '\n'])
1778 .to_string();
1779 return ParsedReasoningResponse {
1780 content,
1781 reasoning: (!reasoning.is_empty()).then_some(reasoning),
1782 };
1783 }
1784 return ParsedReasoningResponse {
1785 content: text.to_string(),
1786 reasoning: None,
1787 };
1788 };
1789
1790 let before = &text[..start];
1791 let after_start = &text[start + THINK_START_TAG.len()..];
1792 let Some(end) = after_start.find(THINK_END_TAG) else {
1793 return ParsedReasoningResponse {
1794 content: before.to_string(),
1795 reasoning: Some(after_start.to_string()),
1796 };
1797 };
1798
1799 let reasoning = after_start[..end].to_string();
1800 let after_end = &after_start[end + THINK_END_TAG.len()..];
1801 let mut content = String::new();
1802 content.push_str(before);
1803 content.push_str(after_end.trim_start_matches(['\r', '\n']));
1804
1805 ParsedReasoningResponse {
1806 content,
1807 reasoning: (!reasoning.is_empty()).then_some(reasoning),
1808 }
1809}
1810
1811fn chat_api_response_from_parsed_generated_text(
1812 chat_request: &ferrum_types::ApiChatRequest,
1813 parsed: &ParsedReasoningResponse,
1814) -> Option<ferrum_types::ApiChatResponse> {
1815 parsed
1816 .reasoning
1817 .as_deref()
1818 .and_then(|reasoning| {
1819 ferrum_types::chat_api_response_from_generated_text(chat_request, reasoning)
1820 })
1821 .or_else(|| {
1822 ferrum_types::chat_api_response_from_generated_text(chat_request, &parsed.content)
1823 })
1824}
1825
1826fn log_required_tool_choice_failure(
1827 request: &ChatCompletionsRequest,
1828 content: &str,
1829 reasoning: Option<&str>,
1830) {
1831 warn!(
1832 model = %request.model,
1833 content_len = content.len(),
1834 content_head = %log_excerpt(content, 512),
1835 reasoning_len = reasoning.map(str::len).unwrap_or(0),
1836 reasoning_head = %reasoning.map(|value| log_excerpt(value, 512)).unwrap_or_default(),
1837 "model output did not satisfy required tool_choice"
1838 );
1839}
1840
1841fn log_excerpt(value: &str, max_chars: usize) -> String {
1842 let mut out = value.chars().take(max_chars).collect::<String>();
1843 if value.chars().count() > max_chars {
1844 out.push_str("...");
1845 }
1846 out
1847}
1848
1849fn normalize_structured_response_content(
1850 request: &ChatCompletionsRequest,
1851 content: &str,
1852) -> String {
1853 let Some(response_format) = request.response_format.as_ref() else {
1854 return content.to_string();
1855 };
1856 match response_format.format_type.as_str() {
1857 "json_object" => extract_json_object_text(content)
1858 .unwrap_or_else(|| strip_markdown_json_fence(content).to_string()),
1859 "json_schema"
1860 if !response_format
1861 .json_schema
1862 .as_ref()
1863 .and_then(|schema| schema.strict)
1864 .unwrap_or(false) =>
1865 {
1866 extract_json_object_text(content)
1867 .unwrap_or_else(|| strip_markdown_json_fence(content).to_string())
1868 }
1869 _ => content.to_string(),
1870 }
1871}
1872
1873fn response_format_is_json_object(request: &ChatCompletionsRequest) -> bool {
1874 request
1875 .response_format
1876 .as_ref()
1877 .is_some_and(|format| format.format_type == "json_object")
1878}
1879
1880fn extract_json_object_text(text: &str) -> Option<String> {
1881 let text = strip_markdown_json_fence(text.trim());
1882 if serde_json::from_str::<serde_json::Value>(&text)
1883 .ok()
1884 .filter(|value| value.is_object())
1885 .is_some()
1886 {
1887 return Some(text.to_string());
1888 }
1889
1890 let start = text.find('{')?;
1891 let mut depth = 0usize;
1892 let mut in_string = false;
1893 let mut escaped = false;
1894 for (offset, ch) in text[start..].char_indices() {
1895 if in_string {
1896 if escaped {
1897 escaped = false;
1898 } else if ch == '\\' {
1899 escaped = true;
1900 } else if ch == '"' {
1901 in_string = false;
1902 }
1903 continue;
1904 }
1905 match ch {
1906 '"' => in_string = true,
1907 '{' => depth += 1,
1908 '}' => {
1909 depth = depth.saturating_sub(1);
1910 if depth == 0 {
1911 let end = start + offset + ch.len_utf8();
1912 let candidate = &text[start..end];
1913 if serde_json::from_str::<serde_json::Value>(candidate)
1914 .ok()
1915 .filter(|value| value.is_object())
1916 .is_some()
1917 {
1918 return Some(candidate.to_string());
1919 }
1920 }
1921 }
1922 _ => {}
1923 }
1924 }
1925 None
1926}
1927
1928fn api_chat_request(
1929 request: &ChatCompletionsRequest,
1930 effective_tool_choice: Option<&ToolChoice>,
1931) -> ferrum_types::ApiChatRequest {
1932 ferrum_types::ApiChatRequest {
1933 messages: request.messages.iter().map(api_chat_message).collect(),
1934 tools: request
1935 .tools
1936 .as_deref()
1937 .unwrap_or_default()
1938 .iter()
1939 .map(api_tool)
1940 .collect(),
1941 tool_choice: effective_tool_choice.map(api_tool_choice),
1942 legacy_functions: request
1943 .functions
1944 .as_deref()
1945 .unwrap_or_default()
1946 .iter()
1947 .map(api_function)
1948 .collect(),
1949 legacy_function_call: request.function_call.as_ref().map(api_function_call_choice),
1950 response_format: request.response_format.as_ref().map(api_response_format),
1951 stream_options: request.stream_options.as_ref().map(|opts| {
1952 ferrum_types::ApiStreamOptions {
1953 include_usage: opts.include_usage,
1954 }
1955 }),
1956 }
1957}
1958
1959fn chat_completion_max_tokens(request: &ChatCompletionsRequest) -> u32 {
1960 request
1961 .max_completion_tokens
1962 .or(request.max_tokens)
1963 .unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS)
1964}
1965
1966fn api_chat_message(message: &ChatMessage) -> ferrum_types::ApiChatMessage {
1967 ferrum_types::ApiChatMessage {
1968 role: match message.role {
1969 MessageRole::System => ferrum_types::ApiMessageRole::System,
1970 MessageRole::User => ferrum_types::ApiMessageRole::User,
1971 MessageRole::Assistant => ferrum_types::ApiMessageRole::Assistant,
1972 MessageRole::Function => ferrum_types::ApiMessageRole::Function,
1973 MessageRole::Tool => ferrum_types::ApiMessageRole::Tool,
1974 },
1975 content: message.content.clone(),
1976 name: message.name.clone(),
1977 tool_calls: message
1978 .tool_calls
1979 .as_deref()
1980 .unwrap_or_default()
1981 .iter()
1982 .map(api_tool_call)
1983 .collect(),
1984 tool_call_id: message.tool_call_id.clone(),
1985 function_call: message.function_call.as_ref().map(api_function_call),
1986 }
1987}
1988
1989fn api_tool(tool: &ChatTool) -> ferrum_types::ApiTool {
1990 ferrum_types::ApiTool {
1991 tool_type: tool.tool_type.clone(),
1992 function: api_function(&tool.function),
1993 }
1994}
1995
1996fn api_function(function: &ChatFunction) -> ferrum_types::ApiFunction {
1997 ferrum_types::ApiFunction {
1998 name: function.name.clone(),
1999 description: function.description.clone(),
2000 parameters: function.parameters.clone(),
2001 strict: function.strict,
2002 }
2003}
2004
2005fn api_tool_choice(choice: &ToolChoice) -> ferrum_types::ApiToolChoice {
2006 match choice {
2007 ToolChoice::Mode(mode) => ferrum_types::ApiToolChoice::Mode(mode.clone()),
2008 ToolChoice::Function {
2009 tool_type,
2010 function,
2011 } => ferrum_types::ApiToolChoice::Function {
2012 tool_type: tool_type.clone(),
2013 function: ferrum_types::ApiToolChoiceFunction {
2014 name: function.name.clone(),
2015 },
2016 },
2017 }
2018}
2019
2020fn api_function_call_choice(choice: &FunctionCallChoice) -> ferrum_types::ApiFunctionCallChoice {
2021 match choice {
2022 FunctionCallChoice::Mode(mode) => ferrum_types::ApiFunctionCallChoice::Mode(mode.clone()),
2023 FunctionCallChoice::Function { name } => {
2024 ferrum_types::ApiFunctionCallChoice::Function { name: name.clone() }
2025 }
2026 }
2027}
2028
2029fn api_tool_call(tool_call: &ChatToolCall) -> ferrum_types::ApiToolCall {
2030 ferrum_types::ApiToolCall {
2031 id: tool_call.id.clone(),
2032 tool_type: tool_call.tool_type.clone(),
2033 function: api_function_call(&tool_call.function),
2034 }
2035}
2036
2037fn api_function_call(function_call: &ChatFunctionCall) -> ferrum_types::ApiFunctionCall {
2038 ferrum_types::ApiFunctionCall {
2039 name: function_call.name.clone(),
2040 arguments: function_call.arguments.clone(),
2041 }
2042}
2043
2044fn openai_chat_message_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
2045 ChatMessage {
2046 role: openai_message_role_from_api(message.role),
2047 content: message.content.clone(),
2048 reasoning: None,
2049 name: message.name.clone(),
2050 tool_calls: if message.tool_calls.is_empty() {
2051 None
2052 } else {
2053 Some(
2054 message
2055 .tool_calls
2056 .iter()
2057 .map(openai_tool_call_from_api)
2058 .collect(),
2059 )
2060 },
2061 tool_call_id: message.tool_call_id.clone(),
2062 function_call: message
2063 .function_call
2064 .as_ref()
2065 .map(openai_function_call_from_api),
2066 }
2067}
2068
2069fn openai_message_role_from_api(role: ferrum_types::ApiMessageRole) -> MessageRole {
2070 match role {
2071 ferrum_types::ApiMessageRole::System => MessageRole::System,
2072 ferrum_types::ApiMessageRole::User => MessageRole::User,
2073 ferrum_types::ApiMessageRole::Assistant => MessageRole::Assistant,
2074 ferrum_types::ApiMessageRole::Function => MessageRole::Function,
2075 ferrum_types::ApiMessageRole::Tool => MessageRole::Tool,
2076 }
2077}
2078
2079fn openai_tool_call_from_api(tool_call: &ferrum_types::ApiToolCall) -> ChatToolCall {
2080 ChatToolCall {
2081 index: None,
2082 id: tool_call.id.clone(),
2083 tool_type: tool_call.tool_type.clone(),
2084 function: openai_function_call_from_api(&tool_call.function),
2085 }
2086}
2087
2088fn openai_tool_call_delta_from_api(
2089 index: usize,
2090 tool_call: &ferrum_types::ApiToolCall,
2091) -> ChatToolCall {
2092 ChatToolCall {
2093 index: Some(usize_to_u32_saturating(index)),
2094 id: tool_call.id.clone(),
2095 tool_type: tool_call.tool_type.clone(),
2096 function: openai_function_call_from_api(&tool_call.function),
2097 }
2098}
2099
2100fn openai_chat_delta_from_api(message: &ferrum_types::ApiChatMessage) -> ChatMessage {
2101 let mut delta = openai_chat_message_from_api(message);
2102 if !message.tool_calls.is_empty() {
2103 delta.tool_calls = Some(
2104 message
2105 .tool_calls
2106 .iter()
2107 .enumerate()
2108 .map(|(index, call)| openai_tool_call_delta_from_api(index, call))
2109 .collect(),
2110 );
2111 }
2112 delta
2113}
2114
2115fn openai_function_call_from_api(
2116 function_call: &ferrum_types::ApiFunctionCall,
2117) -> ChatFunctionCall {
2118 ChatFunctionCall {
2119 name: function_call.name.clone(),
2120 arguments: function_call.arguments.clone(),
2121 }
2122}
2123
2124fn api_response_format(format: &OpenAiResponseFormat) -> ferrum_types::ApiResponseFormat {
2125 ferrum_types::ApiResponseFormat {
2126 format_type: format.format_type.clone(),
2127 json_schema: format
2128 .json_schema
2129 .as_ref()
2130 .map(|schema| ferrum_types::ApiJsonSchema {
2131 name: schema.name.clone(),
2132 schema: schema.schema.clone().unwrap_or(serde_json::Value::Null),
2133 strict: schema.strict,
2134 }),
2135 }
2136}
2137
2138fn validate_chat_request(request: &ChatCompletionsRequest) -> std::result::Result<(), ServerError> {
2139 if request.messages.is_empty() {
2140 return Err(ServerError::invalid_request(
2141 "messages array must not be empty",
2142 Some("messages"),
2143 ));
2144 }
2145
2146 if let Some(n) = request.n {
2147 if n != 1 {
2148 return Err(ServerError::unsupported_feature(
2149 "only n=1 is supported for chat completions",
2150 Some("n"),
2151 ));
2152 }
2153 }
2154
2155 if request
2156 .logit_bias
2157 .as_ref()
2158 .is_some_and(|bias| !bias.is_empty())
2159 {
2160 return Err(ServerError::unsupported_feature(
2161 "logit_bias is not supported",
2162 Some("logit_bias"),
2163 ));
2164 }
2165 if request.logprobs.unwrap_or(false) {
2166 return Err(ServerError::unsupported_feature(
2167 "logprobs is not supported",
2168 Some("logprobs"),
2169 ));
2170 }
2171 if request.top_logprobs.unwrap_or(0) > 0 {
2172 return Err(ServerError::unsupported_feature(
2173 "top_logprobs is not supported",
2174 Some("top_logprobs"),
2175 ));
2176 }
2177
2178 if request.stream_options.is_some() && !request.stream.unwrap_or(false) {
2179 return Err(ServerError::invalid_request(
2180 "stream_options is only valid when stream=true",
2181 Some("stream_options"),
2182 ));
2183 }
2184 ensure_response_format_supported(request)?;
2185
2186 if let Some(tools) = &request.tools {
2187 for tool in tools {
2188 if tool.tool_type != "function" {
2189 return Err(ServerError::unsupported_feature(
2190 "only function tools are supported",
2191 Some("tools"),
2192 ));
2193 }
2194 }
2195 }
2196
2197 if let Some(choice) = &request.tool_choice {
2198 match choice {
2199 ToolChoice::Mode(mode)
2200 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
2201 ToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("required") => {
2202 if request.tools.as_deref().unwrap_or_default().is_empty() {
2203 return Err(ServerError::invalid_request(
2204 "tool_choice=required requires at least one function tool",
2205 Some("tool_choice"),
2206 ));
2207 }
2208 }
2209 ToolChoice::Mode(_) => {
2210 return Err(ServerError::unsupported_feature(
2211 "unsupported tool_choice mode",
2212 Some("tool_choice"),
2213 ));
2214 }
2215 ToolChoice::Function {
2216 tool_type,
2217 function,
2218 } => {
2219 if tool_type != "function" {
2220 return Err(ServerError::unsupported_feature(
2221 "only function tool_choice is supported",
2222 Some("tool_choice"),
2223 ));
2224 }
2225 let declared = request
2226 .tools
2227 .as_deref()
2228 .unwrap_or_default()
2229 .iter()
2230 .any(|tool| tool.function.name == function.name);
2231 if !declared {
2232 return Err(ServerError::invalid_request(
2233 "tool_choice selects a function that is not declared in tools",
2234 Some("tool_choice"),
2235 ));
2236 }
2237 }
2238 }
2239 }
2240
2241 if let Some(choice) = &request.function_call {
2242 match choice {
2243 FunctionCallChoice::Mode(mode)
2244 if mode.eq_ignore_ascii_case("auto") || mode.eq_ignore_ascii_case("none") => {}
2245 FunctionCallChoice::Mode(_) => {
2246 return Err(ServerError::unsupported_feature(
2247 "unsupported function_call mode",
2248 Some("function_call"),
2249 ));
2250 }
2251 FunctionCallChoice::Function { name } => {
2252 let declared = request
2253 .functions
2254 .as_deref()
2255 .unwrap_or_default()
2256 .iter()
2257 .any(|function| function.name == *name);
2258 if !declared {
2259 return Err(ServerError::invalid_request(
2260 "function_call selects a function that is not declared in functions",
2261 Some("function_call"),
2262 ));
2263 }
2264 }
2265 }
2266 }
2267
2268 Ok(())
2269}
2270
2271fn tool_choice_required(request: &ChatCompletionsRequest) -> bool {
2272 match request.tool_choice.as_ref() {
2273 Some(ToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => true,
2274 Some(ToolChoice::Function {
2275 tool_type,
2276 function,
2277 }) => {
2278 tool_type == "function"
2279 && request
2280 .tools
2281 .as_deref()
2282 .unwrap_or_default()
2283 .iter()
2284 .any(|tool| tool.function.name == function.name)
2285 }
2286 _ => false,
2287 }
2288}
2289
2290fn openai_usage_from_token_usage(usage: &TokenUsage) -> Usage {
2291 let prompt_tokens = usize_to_u32_saturating(usage.prompt_tokens);
2292 let completion_tokens = usize_to_u32_saturating(usage.completion_tokens);
2293 let total_tokens = usize_to_u32_saturating(usage.total_tokens);
2294 Usage {
2295 prompt_tokens,
2296 completion_tokens,
2297 total_tokens,
2298 }
2299}
2300
2301fn usize_to_u32_saturating(value: usize) -> u32 {
2302 u32::try_from(value).unwrap_or(u32::MAX)
2303}
2304
2305fn ensure_response_format_supported(
2306 request: &ChatCompletionsRequest,
2307) -> std::result::Result<(), ServerError> {
2308 if let Some(rf) = &request.response_format {
2309 match rf.format_type.as_str() {
2310 "text" | "json_object" => {}
2311 "json_schema" => {
2312 let Some(schema_json) = strict_json_schema_string(request)? else {
2313 if rf.json_schema.is_none() {
2314 return Err(ServerError::invalid_request(
2315 "response_format.json_schema.schema is required",
2316 Some("response_format.json_schema"),
2317 ));
2318 }
2319 return Ok(());
2320 };
2321 ferrum_sampler::schema_to_regex::schema_to_regex(&schema_json).map_err(|e| {
2322 ServerError::unsupported_feature(
2323 format!("unsupported strict json_schema: {e}"),
2324 Some("response_format.json_schema"),
2325 )
2326 })?;
2327 }
2328 _ => {
2329 return Err(ServerError::invalid_request(
2330 "unsupported response_format.type",
2331 Some("response_format.type"),
2332 ));
2333 }
2334 }
2335 }
2336 Ok(())
2337}
2338
2339fn strict_json_schema_string(
2340 request: &ChatCompletionsRequest,
2341) -> std::result::Result<Option<String>, ServerError> {
2342 let Some(rf) = &request.response_format else {
2343 return Ok(None);
2344 };
2345 if rf.format_type != "json_schema" {
2346 return Ok(None);
2347 }
2348 let Some(schema) = &rf.json_schema else {
2349 return Err(ServerError::invalid_request(
2350 "response_format.json_schema.schema is required",
2351 Some("response_format.json_schema"),
2352 ));
2353 };
2354 let Some(schema_value) = schema.schema.as_ref() else {
2355 return Err(ServerError::invalid_request(
2356 "response_format.json_schema.schema is required",
2357 Some("response_format.json_schema"),
2358 ));
2359 };
2360 if !schema.strict.unwrap_or(false) {
2361 return Ok(None);
2362 }
2363 serde_json::to_string(schema_value).map(Some).map_err(|e| {
2364 ServerError::invalid_request(e.to_string(), Some("response_format.json_schema"))
2365 })
2366}
2367
2368fn validate_strict_json_schema_response(
2369 request: &ChatCompletionsRequest,
2370 content: &str,
2371) -> std::result::Result<(), ServerError> {
2372 let Some(schema_json) = strict_json_schema_string(request)? else {
2373 return Ok(());
2374 };
2375 let _parsed_json: serde_json::Value = serde_json::from_str(content).map_err(|e| {
2376 ServerError::InternalError(format!(
2377 "model output did not satisfy response_format.json_schema.strict: invalid JSON: {e}"
2378 ))
2379 })?;
2380 let pattern = ferrum_sampler::schema_to_regex::schema_to_regex(&schema_json).map_err(|e| {
2381 ServerError::InternalError(format!(
2382 "strict json_schema translator failed after validation: {e}"
2383 ))
2384 })?;
2385 let regex = regex_lite::Regex::new(&format!("^(?:{pattern})$")).map_err(|e| {
2386 ServerError::InternalError(format!("strict json_schema validator build failed: {e}"))
2387 })?;
2388 if !regex.is_match(content) {
2389 return Err(ServerError::InternalError(
2390 "model output did not satisfy response_format.json_schema.strict".to_string(),
2391 ));
2392 }
2393 Ok(())
2394}
2395
2396fn strict_stream_validation_error_message(error: ServerError) -> String {
2397 match error {
2398 ServerError::InternalError(message)
2399 | ServerError::NotImplemented(message)
2400 | ServerError::ServiceUnavailable(message)
2401 | ServerError::InvalidRequest { message, .. }
2402 | ServerError::UnsupportedFeature { message, .. } => message,
2403 }
2404}
2405
2406fn server_error_from_ferrum_error(error: Error) -> ServerError {
2407 match error {
2408 Error::RequestValidation { message } => ServerError::invalid_request(message, None),
2409 Error::ResourceExhausted { message } => ServerError::ServiceUnavailable(message),
2410 other => ServerError::InternalError(other.to_string()),
2411 }
2412}
2413
2414fn stream_error_payload(
2415 message: impl Into<String>,
2416 error_type: &str,
2417 param: Option<&str>,
2418) -> OpenAiError {
2419 OpenAiError {
2420 error: OpenAiErrorDetail {
2421 message: message.into(),
2422 error_type: error_type.to_string(),
2423 param: param.map(str::to_string),
2424 code: None,
2425 },
2426 }
2427}
2428
2429fn openai_error_sse_event(
2430 message: impl Into<String>,
2431 error_type: &str,
2432 param: Option<&str>,
2433) -> Event {
2434 Event::default()
2435 .json_data(&stream_error_payload(message, error_type, param))
2436 .unwrap_or_else(|_| Event::default().data("error"))
2437}
2438
2439fn convert_completion_request(request: &CompletionsRequest) -> InferenceRequest {
2440 let prompt = request
2441 .prompt
2442 .as_text()
2443 .expect("completion prompt validated before conversion");
2444 InferenceRequest {
2445 id: RequestId(Uuid::new_v4()),
2446 model_id: ModelId(request.model.clone()),
2447 prompt: prompt.to_string(),
2448 sampling_params: SamplingParams {
2449 max_tokens: request.max_tokens.unwrap_or(DEFAULT_COMPLETION_MAX_TOKENS) as usize,
2450 temperature: request.temperature.unwrap_or(DEFAULT_SAMPLING_TEMPERATURE),
2451 top_p: request.top_p.unwrap_or(DEFAULT_SAMPLING_TOP_P),
2452 top_k: None,
2453 repetition_penalty: 1.0,
2454 presence_penalty: 0.0,
2455 frequency_penalty: 0.0,
2456 stop_sequences: request.stop.clone().unwrap_or_default(),
2457 seed: None,
2458 min_p: None,
2459 tfs: None,
2460 typical_p: None,
2461 mirostat: None,
2462 response_format: ferrum_types::ResponseFormat::Text,
2463 },
2464 stream: request.stream.unwrap_or(false),
2465 priority: Priority::Normal,
2466 client_id: None,
2467 session_id: None,
2468 created_at: chrono::Utc::now(),
2469 api_request: Some(ferrum_types::ApiRequest::Completion(
2470 ferrum_types::ApiCompletionRequest {
2471 prompt: prompt.to_string(),
2472 response_format: None,
2473 },
2474 )),
2475 metadata: if request.max_tokens.is_none() {
2476 HashMap::from([(
2477 DEFAULT_MAX_TOKENS_METADATA_KEY.to_string(),
2478 serde_json::json!(true),
2479 )])
2480 } else {
2481 HashMap::new()
2482 },
2483 }
2484}
2485
2486fn apply_lora_resolution(
2487 inference_request: &mut InferenceRequest,
2488 resolution: Option<&LoraModelResolution>,
2489) {
2490 let Some(resolution) = resolution else {
2491 return;
2492 };
2493 if let Some(adapter) = &resolution.adapter {
2494 inference_request.model_id = ModelId(resolution.base_model_id.clone());
2495 inference_request.metadata.insert(
2496 "ferrum_lora_adapter".to_string(),
2497 serde_json::json!(adapter.name),
2498 );
2499 inference_request.metadata.insert(
2500 "ferrum_lora_model_id".to_string(),
2501 serde_json::json!(adapter.model_id),
2502 );
2503 inference_request.metadata.insert(
2504 "ferrum_lora_path".to_string(),
2505 serde_json::json!(adapter.path),
2506 );
2507 }
2508}
2509
2510async fn handle_completions_sync(
2511 state: AppState,
2512 openai_request: CompletionsRequest,
2513 inference_request: InferenceRequest,
2514) -> std::result::Result<Response, ServerError> {
2515 let engine = state.llm.clone().ok_or_else(|| {
2516 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
2517 })?;
2518 match engine.infer(inference_request).await {
2519 Ok(output) => {
2520 let InferenceResponse {
2521 text: output_text,
2522 finish_reason,
2523 usage,
2524 api_response,
2525 ..
2526 } = output;
2527 let stop_sequences = openai_request.stop.clone().unwrap_or_default();
2528 let mut text = strip_after_stop(&output_text, &stop_sequences);
2529 let mut openai_finish_reason = finish_reason_to_string(&finish_reason);
2530 if let Some(ferrum_types::ApiResponse::Completion(completion_response)) =
2531 api_response.as_ref()
2532 {
2533 text = strip_after_stop(&completion_response.text, &stop_sequences);
2534 if let Some(reason) = &completion_response.finish_reason {
2535 openai_finish_reason = reason.clone();
2536 }
2537 }
2538 let response = CompletionsResponse {
2539 id: Uuid::new_v4().to_string(),
2540 object: "text_completion".to_string(),
2541 created: chrono::Utc::now().timestamp() as u64,
2542 model: openai_request.model,
2543 choices: vec![CompletionChoice {
2544 text,
2545 index: 0,
2546 finish_reason: Some(openai_finish_reason),
2547 }],
2548 usage: Some(openai_usage_from_token_usage(&usage)),
2549 };
2550 Ok(Json(response).into_response())
2551 }
2552 Err(e) => {
2553 error!("Completion generation failed: {}", e);
2554 Err(ServerError::InternalError(e.to_string()))
2555 }
2556 }
2557}
2558
2559async fn handle_completions_stream(
2560 state: AppState,
2561 openai_request: CompletionsRequest,
2562 inference_request: InferenceRequest,
2563) -> std::result::Result<Response, ServerError> {
2564 let (tx, rx) = mpsc::unbounded_channel::<std::result::Result<Event, axum::Error>>();
2565 let engine = state.llm.clone().ok_or_else(|| {
2566 ServerError::ServiceUnavailable("LLM engine not loaded; completions unavailable".into())
2567 })?;
2568 let request_id = Uuid::new_v4().to_string();
2569
2570 tokio::spawn(async move {
2571 match engine.infer_stream(inference_request).await {
2572 Ok(mut stream) => {
2573 while let Some(result) = stream.next().await {
2574 match result {
2575 Ok(chunk) => {
2576 let response_chunk = CompletionsResponse {
2577 id: request_id.clone(),
2578 object: "text_completion".to_string(),
2579 created: chrono::Utc::now().timestamp() as u64,
2580 model: openai_request.model.clone(),
2581 choices: vec![CompletionChoice {
2582 text: chunk.text.clone(),
2583 index: 0,
2584 finish_reason: chunk
2585 .finish_reason
2586 .as_ref()
2587 .map(finish_reason_to_string),
2588 }],
2589 usage: None,
2590 };
2591 let event = Event::default()
2592 .json_data(&response_chunk)
2593 .unwrap_or_else(|_| Event::default().data("error"));
2594 if tx.send(Ok(event)).is_err() {
2595 break;
2596 }
2597 if chunk.finish_reason.is_some() {
2598 if let Some(usage) =
2599 chunk.usage.as_ref().map(openai_usage_from_token_usage)
2600 {
2601 let final_chunk = CompletionsResponse {
2602 id: request_id.clone(),
2603 object: "text_completion".to_string(),
2604 created: chrono::Utc::now().timestamp() as u64,
2605 model: openai_request.model.clone(),
2606 choices: vec![],
2607 usage: Some(usage),
2608 };
2609 let event = Event::default()
2610 .json_data(&final_chunk)
2611 .unwrap_or_else(|_| Event::default().data("error"));
2612 let _ = tx.send(Ok(event));
2613 }
2614 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2615 break;
2616 }
2617 }
2618 Err(e) => {
2619 error!("Completion stream generation error: {}", e);
2620 let _ = tx.send(Ok(openai_error_sse_event(
2621 e.to_string(),
2622 "internal_server_error",
2623 None,
2624 )));
2625 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2626 break;
2627 }
2628 }
2629 }
2630 }
2631 Err(e) => {
2632 error!("Failed to start completion stream: {}", e);
2633 let _ = tx.send(Ok(openai_error_sse_event(
2634 e.to_string(),
2635 "internal_server_error",
2636 None,
2637 )));
2638 let _ = tx.send(Ok(Event::default().data("[DONE]")));
2639 }
2640 }
2641 });
2642
2643 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
2644 Ok(Sse::new(stream).into_response())
2645}
2646
2647async fn completions_handler(
2649 State(state): State<AppState>,
2650 request: std::result::Result<Json<CompletionsRequest>, JsonRejection>,
2651) -> std::result::Result<Response, ServerError> {
2652 let Json(request) = request.map_err(|e| {
2653 ServerError::invalid_request(format!("invalid completions request: {e}"), None)
2654 })?;
2655 validate_completion_request(&request)?;
2656 let loaded_models = state.status().await.loaded_models;
2657 let lora_resolution = state
2658 .lora_registry
2659 .resolve(&request.model, &loaded_models)?;
2660 let mut inference_request = convert_completion_request(&request);
2661 apply_lora_resolution(&mut inference_request, lora_resolution.as_ref());
2662 if request.stream.unwrap_or(false) {
2663 handle_completions_stream(state, request, inference_request).await
2664 } else {
2665 handle_completions_sync(state, request, inference_request).await
2666 }
2667}
2668
2669fn validate_completion_request(
2670 request: &CompletionsRequest,
2671) -> std::result::Result<(), ServerError> {
2672 if request.prompt.as_text().is_none() {
2673 return Err(ServerError::invalid_request(
2674 "only string prompt is supported for completions",
2675 Some("prompt"),
2676 ));
2677 }
2678 if let Some(n) = request.n {
2679 if n != 1 {
2680 return Err(ServerError::unsupported_feature(
2681 "only n=1 is supported for completions",
2682 Some("n"),
2683 ));
2684 }
2685 }
2686 if request.logprobs.is_some() {
2687 return Err(ServerError::unsupported_feature(
2688 "logprobs is not supported for completions",
2689 Some("logprobs"),
2690 ));
2691 }
2692 if request
2693 .logit_bias
2694 .as_ref()
2695 .is_some_and(|bias| !bias.is_empty())
2696 {
2697 return Err(ServerError::unsupported_feature(
2698 "logit_bias is not supported",
2699 Some("logit_bias"),
2700 ));
2701 }
2702 Ok(())
2703}
2704
2705async fn embeddings_handler(
2707 State(state): State<AppState>,
2708 request: std::result::Result<Json<EmbeddingsRequest>, JsonRejection>,
2709) -> std::result::Result<Response, ServerError> {
2710 let Json(request) = request.map_err(|e| {
2711 ServerError::invalid_request(format!("invalid embeddings request: {e}"), None)
2712 })?;
2713
2714 let span = span!(Level::INFO, "embeddings", model = %request.model);
2715 let _enter = span.enter();
2716
2717 validate_embeddings_request(&request)?;
2718
2719 let items: Vec<EmbeddingItem> = match request.input {
2721 EmbeddingInput::Single(text) => vec![EmbeddingItem {
2722 text: Some(text),
2723 image: None,
2724 }],
2725 EmbeddingInput::Batch(texts) => texts
2726 .into_iter()
2727 .map(|t| EmbeddingItem {
2728 text: Some(t),
2729 image: None,
2730 })
2731 .collect(),
2732 EmbeddingInput::SingleObject(item) => vec![item],
2733 EmbeddingInput::BatchObjects(items) => items,
2734 };
2735
2736 if items.is_empty() {
2737 return Err(ServerError::invalid_request(
2738 "input must not be empty",
2739 Some("input"),
2740 ));
2741 }
2742
2743 let mut data = Vec::with_capacity(items.len());
2744 let mut total_tokens = 0u32;
2745
2746 let engine = state.embed.as_ref().ok_or_else(|| {
2747 ServerError::NotImplemented("Embed engine not loaded; embeddings unavailable".into())
2748 })?;
2749 for (idx, item) in items.iter().enumerate() {
2750 let embedding = if let Some(ref image) = item.image {
2751 engine
2752 .embed_image(image)
2753 .await
2754 .map_err(|e| ServerError::InternalError(format!("embed_image: {e}")))?
2755 } else if let Some(ref text) = item.text {
2756 total_tokens += text.len() as u32;
2757 engine
2758 .embed_text(text)
2759 .await
2760 .map_err(|e| ServerError::InternalError(format!("embed_text: {e}")))?
2761 } else {
2762 return Err(ServerError::invalid_request(
2763 "each input item must have either text or image",
2764 Some("input"),
2765 ));
2766 };
2767
2768 data.push(EmbeddingData {
2769 object: "embedding".to_string(),
2770 embedding,
2771 index: idx,
2772 });
2773 }
2774
2775 let response = EmbeddingsResponse {
2776 object: "list".to_string(),
2777 data,
2778 model: request.model,
2779 usage: EmbeddingUsage {
2780 prompt_tokens: total_tokens,
2781 total_tokens,
2782 },
2783 };
2784
2785 Ok(Json(response).into_response())
2786}
2787
2788fn validate_embeddings_request(
2789 request: &EmbeddingsRequest,
2790) -> std::result::Result<(), ServerError> {
2791 if let Some(format) = request.encoding_format.as_deref() {
2792 if !format.eq_ignore_ascii_case("float") {
2793 return Err(ServerError::unsupported_feature(
2794 "only encoding_format=float is supported for embeddings",
2795 Some("encoding_format"),
2796 ));
2797 }
2798 }
2799 Ok(())
2800}
2801
2802async fn transcriptions_handler(
2804 State(state): State<AppState>,
2805 multipart: std::result::Result<axum::extract::Multipart, MultipartRejection>,
2806) -> std::result::Result<Response, ServerError> {
2807 let mut multipart = multipart.map_err(|e| {
2808 ServerError::invalid_request(format!("invalid transcriptions request: {e}"), None)
2809 })?;
2810
2811 let span = span!(Level::INFO, "transcription");
2812 let _enter = span.enter();
2813
2814 let mut file_data: Option<Vec<u8>> = None;
2815 let mut language: Option<String> = None;
2816 let mut response_format: Option<String> = None;
2817
2818 while let Some(field) = multipart
2819 .next_field()
2820 .await
2821 .map_err(|e| ServerError::invalid_request(format!("multipart: {e}"), None))?
2822 {
2823 let name = field.name().unwrap_or("").to_string();
2824 match name.as_str() {
2825 "file" => {
2826 file_data = Some(
2827 field
2828 .bytes()
2829 .await
2830 .map_err(|e| {
2831 ServerError::invalid_request(format!("read file: {e}"), Some("file"))
2832 })?
2833 .to_vec(),
2834 );
2835 }
2836 "language" => {
2837 language = field.text().await.ok().filter(|s| !s.is_empty());
2838 }
2839 "response_format" => {
2840 response_format = field.text().await.ok().filter(|s| !s.is_empty());
2841 }
2842 _ => {} }
2844 }
2845
2846 validate_transcription_response_format(response_format.as_deref())?;
2847
2848 let data = file_data
2849 .ok_or_else(|| ServerError::invalid_request("missing file field", Some("file")))?;
2850
2851 let engine = state.transcribe.as_ref().ok_or_else(|| {
2852 ServerError::NotImplemented("Transcribe engine not loaded; ASR unavailable".into())
2853 })?;
2854 let text = engine
2855 .transcribe_bytes(&data, language.as_deref())
2856 .await
2857 .map_err(|e| ServerError::InternalError(format!("transcribe: {e}")))?;
2858
2859 Ok(Json(TranscriptionResponse { text }).into_response())
2860}
2861
2862fn validate_transcription_response_format(
2863 response_format: Option<&str>,
2864) -> std::result::Result<(), ServerError> {
2865 if let Some(format) = response_format {
2866 if !format.eq_ignore_ascii_case("json") {
2867 return Err(ServerError::unsupported_feature(
2868 "only response_format=json is supported for transcriptions",
2869 Some("response_format"),
2870 ));
2871 }
2872 }
2873 Ok(())
2874}
2875
2876async fn speech_handler(
2878 State(state): State<AppState>,
2879 request: std::result::Result<Json<SpeechRequest>, JsonRejection>,
2880) -> std::result::Result<Response, ServerError> {
2881 let Json(request) = request
2882 .map_err(|e| ServerError::invalid_request(format!("invalid speech request: {e}"), None))?;
2883
2884 let response_format = speech_output_format(&request)?;
2885
2886 let span = span!(Level::INFO, "speech");
2887 let _guard = span.enter();
2888
2889 let language = if request.language.is_empty() || request.language == "auto" {
2890 None
2891 } else {
2892 Some(request.language.as_str())
2893 };
2894
2895 let chunk_frames = 10usize;
2896 let tts = state.tts.as_ref().ok_or_else(|| {
2897 ServerError::NotImplemented("TTS engine not loaded; speech unavailable".into())
2898 })?;
2899 let sample_rate = tts.tts_sample_rate();
2900
2901 if request.stream {
2902 let (tx, rx) =
2904 mpsc::unbounded_channel::<std::result::Result<axum::body::Bytes, std::io::Error>>();
2905
2906 let engine = tts.clone();
2907 let text = request.input.clone();
2908 let lang = request.language.clone();
2909
2910 tokio::task::spawn_blocking(move || {
2911 let lang_opt = if lang.is_empty() || lang == "auto" {
2912 None
2913 } else {
2914 Some(lang.as_str())
2915 };
2916 let rt = tokio::runtime::Handle::current();
2917
2918 match rt.block_on(engine.synthesize_speech(&text, lang_opt, chunk_frames)) {
2919 Ok(chunks) => {
2920 for chunk in &chunks {
2921 let audio_bytes = encode_speech_audio(chunk, sample_rate, response_format);
2922 let _ = tx.send(Ok(axum::body::Bytes::from(audio_bytes)));
2923 }
2924 }
2925 Err(e) => {
2926 error!("TTS error: {e}");
2927 }
2928 }
2929 });
2930
2931 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
2932 let body = axum::body::Body::from_stream(stream);
2933 Ok(Response::builder()
2934 .status(200)
2935 .header("content-type", speech_content_type(response_format))
2936 .header("transfer-encoding", "chunked")
2937 .body(body)
2938 .unwrap())
2939 } else {
2940 let chunks = tts
2942 .synthesize_speech(&request.input, language, chunk_frames)
2943 .await
2944 .map_err(|e| ServerError::InternalError(format!("TTS: {e}")))?;
2945
2946 let all_samples: Vec<f32> = chunks.into_iter().flatten().collect();
2947 let audio_bytes = encode_speech_audio(&all_samples, sample_rate, response_format);
2948
2949 Ok(Response::builder()
2950 .status(200)
2951 .header("content-type", speech_content_type(response_format))
2952 .header("content-length", audio_bytes.len().to_string())
2953 .body(axum::body::Body::from(audio_bytes))
2954 .unwrap())
2955 }
2956}
2957
2958#[derive(Clone, Copy)]
2959enum SpeechOutputFormat {
2960 Wav,
2961 Pcm,
2962}
2963
2964fn speech_output_format(
2965 request: &SpeechRequest,
2966) -> std::result::Result<SpeechOutputFormat, ServerError> {
2967 if request.response_format.eq_ignore_ascii_case("wav") {
2968 Ok(SpeechOutputFormat::Wav)
2969 } else if request.response_format.eq_ignore_ascii_case("pcm") {
2970 Ok(SpeechOutputFormat::Pcm)
2971 } else {
2972 Err(ServerError::unsupported_feature(
2973 "only response_format=wav or response_format=pcm is supported for speech",
2974 Some("response_format"),
2975 ))
2976 }
2977}
2978
2979fn speech_content_type(format: SpeechOutputFormat) -> &'static str {
2980 match format {
2981 SpeechOutputFormat::Wav => "audio/wav",
2982 SpeechOutputFormat::Pcm => "audio/pcm",
2983 }
2984}
2985
2986fn encode_speech_audio(samples: &[f32], sample_rate: u32, format: SpeechOutputFormat) -> Vec<u8> {
2987 match format {
2988 SpeechOutputFormat::Wav => pcm_to_wav_bytes(samples, sample_rate),
2989 SpeechOutputFormat::Pcm => pcm_to_s16le_bytes(samples),
2990 }
2991}
2992
2993fn pcm_to_wav_bytes(samples: &[f32], sample_rate: u32) -> Vec<u8> {
2995 let num_samples = samples.len();
2996 let data_size = num_samples * 2; let file_size = 44 + data_size;
2998
2999 let mut buf = Vec::with_capacity(file_size);
3000 buf.extend_from_slice(b"RIFF");
3002 buf.extend_from_slice(&((file_size - 8) as u32).to_le_bytes());
3003 buf.extend_from_slice(b"WAVE");
3004 buf.extend_from_slice(b"fmt ");
3006 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());
3010 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");
3015 buf.extend_from_slice(&(data_size as u32).to_le_bytes());
3016 buf.extend_from_slice(&pcm_to_s16le_bytes(samples));
3017 buf
3018}
3019
3020fn pcm_to_s16le_bytes(samples: &[f32]) -> Vec<u8> {
3021 let mut buf = Vec::with_capacity(samples.len() * 2);
3022 for &s in samples {
3023 let i16_val = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
3024 buf.extend_from_slice(&i16_val.to_le_bytes());
3025 }
3026 buf
3027}
3028
3029async fn models_handler(
3030 State(state): State<AppState>,
3031) -> std::result::Result<Response, ServerError> {
3032 let status = state.status().await;
3033 let now = chrono::Utc::now().timestamp() as u64;
3034 let mut data: Vec<_> = status
3035 .loaded_models
3036 .into_iter()
3037 .map(|model_id| crate::openai::ModelInfo {
3038 id: model_id.to_string(),
3039 object: "model".to_string(),
3040 created: now,
3041 owned_by: "ferrum".to_string(),
3042 permission: vec![],
3043 root: None,
3044 parent: None,
3045 })
3046 .collect();
3047 data.extend(state.lora_registry.adapter_models().iter().map(|adapter| {
3048 crate::openai::ModelInfo {
3049 id: adapter.model_id.clone(),
3050 object: "model".to_string(),
3051 created: now,
3052 owned_by: "ferrum".to_string(),
3053 permission: vec![],
3054 root: state.lora_registry.base_model_id.as_ref().cloned(),
3055 parent: state.lora_registry.base_model_id.as_ref().cloned(),
3056 }
3057 }));
3058
3059 let models = ModelListResponse {
3060 object: "list".to_string(),
3061 data,
3062 };
3063
3064 Ok(Json(models).into_response())
3065}
3066
3067async fn health_handler(
3068 State(state): State<AppState>,
3069) -> std::result::Result<Response, ServerError> {
3070 let engine_status = state.status().await;
3071 let scheduler_metrics = state.metrics();
3072 let runtime_config = RuntimeConfigSnapshot::capture_current();
3073 let cache_policy = CachePolicy::current();
3074 let engine_cache = state
3075 .llm
3076 .as_ref()
3077 .and_then(|engine| engine.cache_metrics_snapshot());
3078 let engine_lora = state
3079 .llm
3080 .as_ref()
3081 .and_then(|engine| engine.lora_metrics_snapshot());
3082 let auto_config = auto_config_health_value(state.auto_config.as_ref());
3083 let admission = admission_health_json(&engine_status, &scheduler_metrics, &auto_config);
3084
3085 let health = serde_json::json!({
3086 "status": "healthy",
3087 "timestamp": chrono::Utc::now().to_rfc3339(),
3088 "version": env!("CARGO_PKG_VERSION"),
3089 "engine": {
3090 "active_requests": engine_status.active_requests,
3091 "queued_requests": engine_status.queued_requests,
3092 },
3093 "scheduler": {
3094 "total_requests": scheduler_metrics.total_requests,
3095 "successful_requests": scheduler_metrics.successful_requests,
3096 "failed_requests": scheduler_metrics.failed_requests,
3097 "throughput_rps": scheduler_metrics.throughput_rps,
3098 "avg_wait_time_ms": scheduler_metrics.queue_metrics.avg_queue_wait_time_ms,
3099 "scheduling_time_ms": scheduler_metrics.performance_breakdown.scheduling_time_ms,
3100 "model_execution_time_ms": scheduler_metrics
3101 .performance_breakdown
3102 .model_execution_time_ms,
3103 "iteration_lock_wait_time_ms": scheduler_metrics
3104 .performance_breakdown
3105 .other_overhead_time_ms,
3106 },
3107 "config": runtime_config,
3108 "auto_config": auto_config,
3109 "admission": admission,
3110 "cache": state.cache.health_json(&cache_policy, engine_cache.as_ref()),
3111 "lora": engine_lora.unwrap_or_else(|| serde_json::json!({
3112 "enabled": state.lora_registry.is_enabled(),
3113 "adapter_count": state.lora_registry.adapter_models().len() as u64,
3114 "active_cache_bindings": 0u64,
3115 "projection_applications": 0u64,
3116 "position": "startup-routing",
3117 "source": "server-lora-registry",
3118 })),
3119 });
3120
3121 Ok(Json(health).into_response())
3122}
3123
3124async fn metrics_handler(
3126 State(state): State<AppState>,
3127) -> std::result::Result<Response, ServerError> {
3128 let mut body = match PROM_HANDLE.get() {
3129 Some(handle) => handle.render(),
3130 None => "# Prometheus recorder not initialized\n".to_string(),
3131 };
3132 if !body.ends_with('\n') {
3133 body.push('\n');
3134 }
3135 let engine_cache = state
3136 .llm
3137 .as_ref()
3138 .and_then(|engine| engine.cache_metrics_snapshot());
3139 body.push_str(&state.cache.prometheus_metrics(engine_cache.as_ref()));
3140 let engine_status = state.status().await;
3141 let scheduler_metrics = state.metrics();
3142 let auto_config = auto_config_health_value(state.auto_config.as_ref());
3143 let admission = admission_health_json(&engine_status, &scheduler_metrics, &auto_config);
3144 body.push_str(&admission_prometheus_metrics(&admission));
3145
3146 Ok((
3147 [(
3148 axum::http::header::CONTENT_TYPE,
3149 "text/plain; version=0.0.4; charset=utf-8",
3150 )],
3151 body,
3152 )
3153 .into_response())
3154}
3155
3156async fn root_handler() -> std::result::Result<Response, ServerError> {
3157 let info = serde_json::json!({
3158 "name": "Ferrum Inference Server",
3159 "version": env!("CARGO_PKG_VERSION"),
3160 "api_version": "v1",
3161 "status": "running"
3162 });
3163
3164 Ok(Json(info).into_response())
3165}
3166
3167#[derive(Debug)]
3169enum ServerError {
3170 InvalidRequest {
3171 message: String,
3172 param: Option<String>,
3173 },
3174 UnsupportedFeature {
3175 message: String,
3176 param: Option<String>,
3177 },
3178 InternalError(String),
3179 NotImplemented(String),
3180 ServiceUnavailable(String),
3181}
3182
3183impl ServerError {
3184 fn invalid_request(message: impl Into<String>, param: Option<&str>) -> Self {
3185 Self::InvalidRequest {
3186 message: message.into(),
3187 param: param.map(str::to_string),
3188 }
3189 }
3190
3191 fn unsupported_feature(message: impl Into<String>, param: Option<&str>) -> Self {
3192 Self::UnsupportedFeature {
3193 message: message.into(),
3194 param: param.map(str::to_string),
3195 }
3196 }
3197}
3198
3199impl IntoResponse for ServerError {
3200 fn into_response(self) -> Response {
3201 let (status, message, error_type, param) = match self {
3202 ServerError::InvalidRequest { message, param } => (
3203 AxumStatusCode::BAD_REQUEST,
3204 message,
3205 "invalid_request_error",
3206 param,
3207 ),
3208 ServerError::UnsupportedFeature { message, param } => (
3209 AxumStatusCode::BAD_REQUEST,
3210 message,
3211 "invalid_request_error",
3212 param,
3213 ),
3214 ServerError::InternalError(msg) => (
3215 AxumStatusCode::INTERNAL_SERVER_ERROR,
3216 msg,
3217 "internal_server_error",
3218 None,
3219 ),
3220 ServerError::NotImplemented(msg) => (
3221 AxumStatusCode::SERVICE_UNAVAILABLE,
3222 msg,
3223 "service_unavailable_error",
3224 None,
3225 ),
3226 ServerError::ServiceUnavailable(msg) => (
3227 AxumStatusCode::SERVICE_UNAVAILABLE,
3228 msg,
3229 "service_unavailable_error",
3230 None,
3231 ),
3232 };
3233
3234 let error = OpenAiError {
3235 error: OpenAiErrorDetail {
3236 message,
3237 error_type: error_type.to_string(),
3238 param,
3239 code: None,
3240 },
3241 };
3242
3243 (status, Json(error)).into_response()
3244 }
3245}
3246
3247impl std::fmt::Display for MessageRole {
3248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3249 match self {
3250 MessageRole::System => write!(f, "system"),
3251 MessageRole::User => write!(f, "user"),
3252 MessageRole::Assistant => write!(f, "assistant"),
3253 MessageRole::Function => write!(f, "function"),
3254 MessageRole::Tool => write!(f, "tool"),
3255 }
3256 }
3257}
3258
3259fn strip_after_stop(text: &str, stops: &[String]) -> String {
3263 let mut first: Option<usize> = None;
3264 for stop in stops {
3265 if stop.is_empty() {
3266 continue;
3267 }
3268 if let Some(idx) = text.find(stop.as_str()) {
3269 first = Some(first.map_or(idx, |current| current.min(idx)));
3270 }
3271 }
3272 match first {
3273 Some(idx) => text[..idx].to_string(),
3274 None => text.to_string(),
3275 }
3276}
3277
3278fn strip_markdown_json_fence(text: &str) -> String {
3287 let trimmed = text.trim();
3288 for prefix in ["```json\n", "```json", "```\n", "```"] {
3290 if let Some(rest) = trimmed.strip_prefix(prefix) {
3291 if let Some(inner) = rest.strip_suffix("```") {
3292 return inner.trim().to_string();
3293 }
3294 }
3295 }
3296 text.to_string()
3297}
3298
3299fn finish_reason_to_string(reason: &FinishReason) -> String {
3301 match reason {
3302 FinishReason::Length => "length".to_string(),
3303 FinishReason::Stop => "stop".to_string(),
3304 FinishReason::EOS => "stop".to_string(),
3305 FinishReason::Cancelled => "cancelled".to_string(),
3306 FinishReason::Error => "error".to_string(),
3307 FinishReason::ContentFilter => "content_filter".to_string(),
3308 }
3309}
3310
3311#[cfg(test)]
3312mod tests {
3313 use super::*;
3314 use async_trait::async_trait;
3315 use axum::{
3316 body::{to_bytes, Body},
3317 http::{header, Request},
3318 response::Response,
3319 };
3320 use ferrum_interfaces::engine::{
3321 EmbedEngine, InferenceEngine, LlmInferenceEngine, TranscribeEngine, TtsEngine,
3322 };
3323 use ferrum_types::{
3324 EngineConfig, EngineMetrics, EngineStatus, FinishReason,
3325 HealthStatus as EngineHealthStatus, InferenceRequest, InferenceResponse, MemoryUsage,
3326 ModelId, StreamChunk, TokenId, TokenUsage,
3327 };
3328 use futures::{stream, Stream};
3329 use serde_json::{json, Value};
3330 use std::{
3331 collections::HashMap,
3332 pin::Pin,
3333 sync::{Arc, Mutex},
3334 };
3335 use tower::ServiceExt;
3336
3337 #[test]
3338 fn strip_after_stop_removes_first_boundary() {
3339 assert_eq!(
3340 strip_after_stop(
3341 "KS0214Z\nS0225\nEND0214Z0214Z\nS0225\n",
3342 &["END0214Z".to_string()]
3343 ),
3344 "KS0214Z\nS0225\n"
3345 );
3346 }
3347
3348 struct StubLlm {
3349 config: EngineConfig,
3350 text: String,
3351 stream_chunks: Option<Vec<String>>,
3352 stream_final_chunk_separate: bool,
3353 stream_usage: Option<TokenUsage>,
3354 api_response: Option<ferrum_types::ApiResponse>,
3355 lora_metrics: Option<Value>,
3356 }
3357
3358 impl StubLlm {
3359 fn new(text: &str) -> Self {
3360 let mut config = EngineConfig::default();
3361 config.model.model_id = ModelId::new("stub-model");
3362 Self {
3363 config,
3364 text: text.to_string(),
3365 stream_chunks: None,
3366 stream_final_chunk_separate: false,
3367 stream_usage: Some(TokenUsage::new(5, 1)),
3368 api_response: None,
3369 lora_metrics: None,
3370 }
3371 }
3372
3373 fn without_stream_usage(text: &str) -> Self {
3374 Self {
3375 stream_usage: None,
3376 ..Self::new(text)
3377 }
3378 }
3379
3380 fn with_stream_chunks(chunks: &[&str]) -> Self {
3381 Self {
3382 text: chunks.concat(),
3383 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
3384 ..Self::new("")
3385 }
3386 }
3387
3388 fn with_separate_final_stream_chunk(chunks: &[&str]) -> Self {
3389 Self {
3390 text: chunks.concat(),
3391 stream_chunks: Some(chunks.iter().map(|chunk| (*chunk).to_string()).collect()),
3392 stream_final_chunk_separate: true,
3393 ..Self::new("")
3394 }
3395 }
3396
3397 fn with_api_response(text: &str, api_response: ferrum_types::ApiResponse) -> Self {
3398 Self {
3399 api_response: Some(api_response),
3400 ..Self::new(text)
3401 }
3402 }
3403
3404 fn with_lora_metrics(text: &str, lora_metrics: Value) -> Self {
3405 Self {
3406 lora_metrics: Some(lora_metrics),
3407 ..Self::new(text)
3408 }
3409 }
3410 }
3411
3412 struct StubEmbed {
3413 config: EngineConfig,
3414 }
3415
3416 impl StubEmbed {
3417 fn new() -> Self {
3418 let mut config = EngineConfig::default();
3419 config.model.model_id = ModelId::new("stub-embed");
3420 Self { config }
3421 }
3422 }
3423
3424 struct StubTranscribe {
3425 config: EngineConfig,
3426 }
3427
3428 impl StubTranscribe {
3429 fn new() -> Self {
3430 let mut config = EngineConfig::default();
3431 config.model.model_id = ModelId::new("stub-transcribe");
3432 Self { config }
3433 }
3434 }
3435
3436 struct StubTts {
3437 config: EngineConfig,
3438 }
3439
3440 impl StubTts {
3441 fn new() -> Self {
3442 let mut config = EngineConfig::default();
3443 config.model.model_id = ModelId::new("stub-tts");
3444 Self { config }
3445 }
3446 }
3447
3448 struct FailingLlm {
3449 config: EngineConfig,
3450 fail_after_stream_start: bool,
3451 }
3452
3453 impl FailingLlm {
3454 fn new() -> Self {
3455 let mut config = EngineConfig::default();
3456 config.model.model_id = ModelId::new("failing-model");
3457 Self {
3458 config,
3459 fail_after_stream_start: false,
3460 }
3461 }
3462
3463 fn after_stream_start() -> Self {
3464 Self {
3465 fail_after_stream_start: true,
3466 ..Self::new()
3467 }
3468 }
3469 }
3470
3471 struct CapturingLlm {
3472 config: EngineConfig,
3473 last_request: Mutex<Option<InferenceRequest>>,
3474 }
3475
3476 impl CapturingLlm {
3477 fn new() -> Self {
3478 let mut config = EngineConfig::default();
3479 config.model.model_id = ModelId::new("qwen3");
3480 Self {
3481 config,
3482 last_request: Mutex::new(None),
3483 }
3484 }
3485
3486 fn last_request(&self) -> InferenceRequest {
3487 self.last_request
3488 .lock()
3489 .expect("capture lock")
3490 .clone()
3491 .expect("request captured")
3492 }
3493 }
3494
3495 #[async_trait]
3496 impl InferenceEngine for StubLlm {
3497 async fn status(&self) -> EngineStatus {
3498 EngineStatus {
3499 is_ready: true,
3500 loaded_models: vec![self.config.model.model_id.clone()],
3501 active_requests: 0,
3502 queued_requests: 0,
3503 memory_usage: MemoryUsage {
3504 total_bytes: 0,
3505 used_bytes: 0,
3506 free_bytes: 0,
3507 gpu_memory_bytes: None,
3508 cpu_memory_bytes: None,
3509 cache_memory_bytes: 0,
3510 utilization_percent: 0.0,
3511 },
3512 uptime_seconds: 0,
3513 last_heartbeat: chrono::Utc::now(),
3514 version: "test".to_string(),
3515 }
3516 }
3517
3518 async fn shutdown(&self) -> ferrum_types::Result<()> {
3519 Ok(())
3520 }
3521
3522 fn config(&self) -> &EngineConfig {
3523 &self.config
3524 }
3525
3526 fn metrics(&self) -> EngineMetrics {
3527 EngineMetrics::default()
3528 }
3529
3530 async fn health_check(&self) -> EngineHealthStatus {
3531 EngineHealthStatus::healthy()
3532 }
3533
3534 fn lora_metrics_snapshot(&self) -> Option<Value> {
3535 self.lora_metrics.clone()
3536 }
3537 }
3538
3539 #[async_trait]
3540 impl InferenceEngine for StubEmbed {
3541 async fn status(&self) -> EngineStatus {
3542 EngineStatus {
3543 is_ready: true,
3544 loaded_models: vec![self.config.model.model_id.clone()],
3545 active_requests: 0,
3546 queued_requests: 0,
3547 memory_usage: MemoryUsage {
3548 total_bytes: 0,
3549 used_bytes: 0,
3550 free_bytes: 0,
3551 gpu_memory_bytes: None,
3552 cpu_memory_bytes: None,
3553 cache_memory_bytes: 0,
3554 utilization_percent: 0.0,
3555 },
3556 uptime_seconds: 0,
3557 last_heartbeat: chrono::Utc::now(),
3558 version: "test".to_string(),
3559 }
3560 }
3561
3562 async fn shutdown(&self) -> ferrum_types::Result<()> {
3563 Ok(())
3564 }
3565
3566 fn config(&self) -> &EngineConfig {
3567 &self.config
3568 }
3569
3570 fn metrics(&self) -> EngineMetrics {
3571 EngineMetrics::default()
3572 }
3573
3574 async fn health_check(&self) -> EngineHealthStatus {
3575 EngineHealthStatus::healthy()
3576 }
3577 }
3578
3579 #[async_trait]
3580 impl EmbedEngine for StubEmbed {
3581 async fn embed_text(&self, text: &str) -> ferrum_types::Result<Vec<f32>> {
3582 Ok(vec![text.len() as f32, 1.0, 0.0])
3583 }
3584
3585 async fn embed_image(&self, image: &str) -> ferrum_types::Result<Vec<f32>> {
3586 Ok(vec![image.len() as f32, 0.0, 1.0])
3587 }
3588
3589 fn embedding_dim(&self) -> usize {
3590 3
3591 }
3592 }
3593
3594 #[async_trait]
3595 impl InferenceEngine for StubTranscribe {
3596 async fn status(&self) -> EngineStatus {
3597 EngineStatus {
3598 is_ready: true,
3599 loaded_models: vec![self.config.model.model_id.clone()],
3600 active_requests: 0,
3601 queued_requests: 0,
3602 memory_usage: MemoryUsage {
3603 total_bytes: 0,
3604 used_bytes: 0,
3605 free_bytes: 0,
3606 gpu_memory_bytes: None,
3607 cpu_memory_bytes: None,
3608 cache_memory_bytes: 0,
3609 utilization_percent: 0.0,
3610 },
3611 uptime_seconds: 0,
3612 last_heartbeat: chrono::Utc::now(),
3613 version: "test".to_string(),
3614 }
3615 }
3616
3617 async fn shutdown(&self) -> ferrum_types::Result<()> {
3618 Ok(())
3619 }
3620
3621 fn config(&self) -> &EngineConfig {
3622 &self.config
3623 }
3624
3625 fn metrics(&self) -> EngineMetrics {
3626 EngineMetrics::default()
3627 }
3628
3629 async fn health_check(&self) -> EngineHealthStatus {
3630 EngineHealthStatus::healthy()
3631 }
3632 }
3633
3634 #[async_trait]
3635 impl TranscribeEngine for StubTranscribe {
3636 async fn transcribe_file(
3637 &self,
3638 path: &str,
3639 language: Option<&str>,
3640 ) -> ferrum_types::Result<String> {
3641 Ok(format!("file:{path}:{}", language.unwrap_or("auto")))
3642 }
3643
3644 async fn transcribe_bytes(
3645 &self,
3646 data: &[u8],
3647 language: Option<&str>,
3648 ) -> ferrum_types::Result<String> {
3649 Ok(format!(
3650 "bytes:{}:{}",
3651 data.len(),
3652 language.unwrap_or("auto")
3653 ))
3654 }
3655 }
3656
3657 #[async_trait]
3658 impl InferenceEngine for StubTts {
3659 async fn status(&self) -> EngineStatus {
3660 EngineStatus {
3661 is_ready: true,
3662 loaded_models: vec![self.config.model.model_id.clone()],
3663 active_requests: 0,
3664 queued_requests: 0,
3665 memory_usage: MemoryUsage {
3666 total_bytes: 0,
3667 used_bytes: 0,
3668 free_bytes: 0,
3669 gpu_memory_bytes: None,
3670 cpu_memory_bytes: None,
3671 cache_memory_bytes: 0,
3672 utilization_percent: 0.0,
3673 },
3674 uptime_seconds: 0,
3675 last_heartbeat: chrono::Utc::now(),
3676 version: "test".to_string(),
3677 }
3678 }
3679
3680 async fn shutdown(&self) -> ferrum_types::Result<()> {
3681 Ok(())
3682 }
3683
3684 fn config(&self) -> &EngineConfig {
3685 &self.config
3686 }
3687
3688 fn metrics(&self) -> EngineMetrics {
3689 EngineMetrics::default()
3690 }
3691
3692 async fn health_check(&self) -> EngineHealthStatus {
3693 EngineHealthStatus::healthy()
3694 }
3695 }
3696
3697 #[async_trait]
3698 impl TtsEngine for StubTts {
3699 async fn synthesize_speech(
3700 &self,
3701 _text: &str,
3702 _language: Option<&str>,
3703 _chunk_frames: usize,
3704 ) -> ferrum_types::Result<Vec<Vec<f32>>> {
3705 Ok(vec![vec![0.0, 0.5, -0.5]])
3706 }
3707
3708 fn tts_sample_rate(&self) -> u32 {
3709 16_000
3710 }
3711 }
3712
3713 #[async_trait]
3714 impl InferenceEngine for FailingLlm {
3715 async fn status(&self) -> EngineStatus {
3716 EngineStatus {
3717 is_ready: true,
3718 loaded_models: vec![self.config.model.model_id.clone()],
3719 active_requests: 0,
3720 queued_requests: 0,
3721 memory_usage: MemoryUsage {
3722 total_bytes: 0,
3723 used_bytes: 0,
3724 free_bytes: 0,
3725 gpu_memory_bytes: None,
3726 cpu_memory_bytes: None,
3727 cache_memory_bytes: 0,
3728 utilization_percent: 0.0,
3729 },
3730 uptime_seconds: 0,
3731 last_heartbeat: chrono::Utc::now(),
3732 version: "test".to_string(),
3733 }
3734 }
3735
3736 async fn shutdown(&self) -> ferrum_types::Result<()> {
3737 Ok(())
3738 }
3739
3740 fn config(&self) -> &EngineConfig {
3741 &self.config
3742 }
3743
3744 fn metrics(&self) -> EngineMetrics {
3745 EngineMetrics::default()
3746 }
3747
3748 async fn health_check(&self) -> EngineHealthStatus {
3749 EngineHealthStatus::healthy()
3750 }
3751 }
3752
3753 #[async_trait]
3754 impl InferenceEngine for CapturingLlm {
3755 async fn status(&self) -> EngineStatus {
3756 EngineStatus {
3757 is_ready: true,
3758 loaded_models: vec![self.config.model.model_id.clone()],
3759 active_requests: 0,
3760 queued_requests: 0,
3761 memory_usage: MemoryUsage {
3762 total_bytes: 0,
3763 used_bytes: 0,
3764 free_bytes: 0,
3765 gpu_memory_bytes: None,
3766 cpu_memory_bytes: None,
3767 cache_memory_bytes: 0,
3768 utilization_percent: 0.0,
3769 },
3770 uptime_seconds: 0,
3771 last_heartbeat: chrono::Utc::now(),
3772 version: "test".to_string(),
3773 }
3774 }
3775
3776 async fn shutdown(&self) -> ferrum_types::Result<()> {
3777 Ok(())
3778 }
3779
3780 fn config(&self) -> &EngineConfig {
3781 &self.config
3782 }
3783
3784 fn metrics(&self) -> EngineMetrics {
3785 EngineMetrics::default()
3786 }
3787
3788 async fn health_check(&self) -> EngineHealthStatus {
3789 EngineHealthStatus::healthy()
3790 }
3791 }
3792
3793 #[async_trait]
3794 impl LlmInferenceEngine for StubLlm {
3795 async fn infer(
3796 &self,
3797 request: InferenceRequest,
3798 ) -> ferrum_types::Result<InferenceResponse> {
3799 Ok(InferenceResponse {
3800 request_id: request.id,
3801 text: self.text.clone(),
3802 tokens: vec![TokenId::new(11), TokenId::new(12)],
3803 finish_reason: FinishReason::Stop,
3804 usage: TokenUsage::new(7, 2),
3805 latency_ms: 1,
3806 created_at: chrono::Utc::now(),
3807 metadata: HashMap::new(),
3808 api_response: self.api_response.clone(),
3809 })
3810 }
3811
3812 async fn infer_stream(
3813 &self,
3814 request: InferenceRequest,
3815 ) -> ferrum_types::Result<
3816 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
3817 > {
3818 if let Some(chunks) = &self.stream_chunks {
3819 let request_id = request.id;
3820 let mut stream_chunks = Vec::with_capacity(
3821 chunks.len() + usize::from(self.stream_final_chunk_separate),
3822 );
3823 let last = chunks.len().saturating_sub(1);
3824 for (index, text) in chunks.iter().enumerate() {
3825 let is_final_text_chunk = index == last && !self.stream_final_chunk_separate;
3826 stream_chunks.push(Ok(StreamChunk {
3827 request_id: request_id.clone(),
3828 text: text.clone(),
3829 token: Some(TokenId::new(11 + index as u32)),
3830 finish_reason: is_final_text_chunk.then_some(FinishReason::Stop),
3831 usage: is_final_text_chunk
3832 .then(|| self.stream_usage.clone())
3833 .flatten(),
3834 created_at: chrono::Utc::now(),
3835 metadata: HashMap::new(),
3836 api_response: is_final_text_chunk
3837 .then(|| self.api_response.clone())
3838 .flatten(),
3839 }));
3840 }
3841 if self.stream_final_chunk_separate {
3842 stream_chunks.push(Ok(StreamChunk {
3843 request_id,
3844 text: String::new(),
3845 token: None,
3846 finish_reason: Some(FinishReason::Stop),
3847 usage: self.stream_usage.clone(),
3848 created_at: chrono::Utc::now(),
3849 metadata: HashMap::new(),
3850 api_response: self.api_response.clone(),
3851 }));
3852 }
3853 return Ok(Box::pin(stream::iter(stream_chunks)));
3854 }
3855
3856 let chunk = StreamChunk {
3857 request_id: request.id,
3858 text: self.text.clone(),
3859 token: Some(TokenId::new(11)),
3860 finish_reason: Some(FinishReason::Stop),
3861 usage: self.stream_usage.clone(),
3862 created_at: chrono::Utc::now(),
3863 metadata: HashMap::new(),
3864 api_response: self.api_response.clone(),
3865 };
3866 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
3867 }
3868 }
3869
3870 #[async_trait]
3871 impl LlmInferenceEngine for FailingLlm {
3872 async fn infer(
3873 &self,
3874 _request: InferenceRequest,
3875 ) -> ferrum_types::Result<InferenceResponse> {
3876 Err(ferrum_types::FerrumError::internal(
3877 "stub generation failed",
3878 ))
3879 }
3880
3881 async fn infer_stream(
3882 &self,
3883 request: InferenceRequest,
3884 ) -> ferrum_types::Result<
3885 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
3886 > {
3887 if self.fail_after_stream_start {
3888 let _request_id = request.id;
3889 return Ok(Box::pin(stream::iter(vec![Err(
3890 ferrum_types::FerrumError::internal("stub stream chunk failed"),
3891 )])));
3892 }
3893 Err(ferrum_types::FerrumError::internal("stub stream failed"))
3894 }
3895 }
3896
3897 #[async_trait]
3898 impl LlmInferenceEngine for CapturingLlm {
3899 async fn infer(
3900 &self,
3901 request: InferenceRequest,
3902 ) -> ferrum_types::Result<InferenceResponse> {
3903 *self.last_request.lock().expect("capture lock") = Some(request.clone());
3904 Ok(InferenceResponse {
3905 request_id: request.id,
3906 text: "captured".to_string(),
3907 tokens: vec![TokenId::new(21)],
3908 finish_reason: FinishReason::Stop,
3909 usage: TokenUsage::new(9, 1),
3910 latency_ms: 1,
3911 created_at: chrono::Utc::now(),
3912 metadata: HashMap::new(),
3913 api_response: None,
3914 })
3915 }
3916
3917 async fn infer_stream(
3918 &self,
3919 request: InferenceRequest,
3920 ) -> ferrum_types::Result<
3921 Pin<Box<dyn Stream<Item = ferrum_types::Result<StreamChunk>> + Send>>,
3922 > {
3923 *self.last_request.lock().expect("capture lock") = Some(request.clone());
3924 let chunk = StreamChunk {
3925 request_id: request.id,
3926 text: "captured".to_string(),
3927 token: Some(TokenId::new(21)),
3928 finish_reason: Some(FinishReason::Stop),
3929 usage: Some(TokenUsage::new(9, 1)),
3930 created_at: chrono::Utc::now(),
3931 metadata: HashMap::new(),
3932 api_response: None,
3933 };
3934 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
3935 }
3936 }
3937
3938 fn state_with_stub(text: &str) -> AppState {
3939 AppState::default().with_llm(Arc::new(StubLlm::new(text)))
3940 }
3941
3942 fn router_with_stub(text: &str) -> Router {
3943 AxumServer::from_llm(Arc::new(StubLlm::new(text))).build_router()
3944 }
3945
3946 fn router_with_stub_stream_chunks(chunks: &[&str]) -> Router {
3947 AxumServer::from_llm(Arc::new(StubLlm::with_stream_chunks(chunks))).build_router()
3948 }
3949
3950 fn router_with_stub_separate_final_stream_chunk(chunks: &[&str]) -> Router {
3951 AxumServer::from_llm(Arc::new(StubLlm::with_separate_final_stream_chunk(chunks)))
3952 .build_router()
3953 }
3954
3955 fn router_with_stub_api_response(
3956 text: &str,
3957 api_response: ferrum_types::ApiResponse,
3958 ) -> Router {
3959 AxumServer::from_llm(Arc::new(StubLlm::with_api_response(text, api_response)))
3960 .build_router()
3961 }
3962
3963 fn router_with_stub_without_stream_usage(text: &str) -> Router {
3964 AxumServer::from_llm(Arc::new(StubLlm::without_stream_usage(text))).build_router()
3965 }
3966
3967 fn router_without_llm() -> Router {
3968 AxumServer::from_state(AppState::default()).build_router()
3969 }
3970
3971 fn router_with_failing_llm() -> Router {
3972 AxumServer::from_llm(Arc::new(FailingLlm::new())).build_router()
3973 }
3974
3975 fn router_with_stream_chunk_failing_llm() -> Router {
3976 AxumServer::from_llm(Arc::new(FailingLlm::after_stream_start())).build_router()
3977 }
3978
3979 fn router_with_capturing_llm() -> (Router, Arc<CapturingLlm>) {
3980 let engine = Arc::new(CapturingLlm::new());
3981 let router = AxumServer::from_llm(engine.clone()).build_router();
3982 (router, engine)
3983 }
3984
3985 fn router_with_capturing_llm_and_template(
3986 template: ModelChatTemplate,
3987 ) -> (Router, Arc<CapturingLlm>) {
3988 let engine = Arc::new(CapturingLlm::new());
3989 let router = AxumServer::from_llm(engine.clone())
3990 .with_prompt_template(Some(template))
3991 .build_router();
3992 (router, engine)
3993 }
3994
3995 fn router_with_capturing_lora_llm() -> (Router, Arc<CapturingLlm>) {
3996 let engine = Arc::new(CapturingLlm::new());
3997 let router = AxumServer::from_llm(engine.clone())
3998 .with_lora_adapters(
3999 "qwen3",
4000 vec![LoraAdapterModel::new(
4001 "sql",
4002 "qwen3:sql",
4003 "/tmp/sql-adapter",
4004 )],
4005 )
4006 .build_router();
4007 (router, engine)
4008 }
4009
4010 fn router_with_stub_embed() -> Router {
4011 AxumServer::from_embed(Arc::new(StubEmbed::new())).build_router()
4012 }
4013
4014 fn router_with_stub_transcribe() -> Router {
4015 AxumServer::from_transcribe(Arc::new(StubTranscribe::new())).build_router()
4016 }
4017
4018 fn router_with_stub_tts() -> Router {
4019 AxumServer::from_tts(Arc::new(StubTts::new())).build_router()
4020 }
4021
4022 async fn post_json(app: Router, path: &str, body: Value) -> Response {
4023 app.oneshot(
4024 Request::builder()
4025 .method("POST")
4026 .uri(path)
4027 .header(header::CONTENT_TYPE, "application/json")
4028 .body(Body::from(body.to_string()))
4029 .expect("request"),
4030 )
4031 .await
4032 .expect("route response")
4033 }
4034
4035 async fn post_raw_json(app: Router, path: &str, body: &str) -> Response {
4036 app.oneshot(
4037 Request::builder()
4038 .method("POST")
4039 .uri(path)
4040 .header(header::CONTENT_TYPE, "application/json")
4041 .body(Body::from(body.to_string()))
4042 .expect("request"),
4043 )
4044 .await
4045 .expect("route response")
4046 }
4047
4048 async fn post_multipart(app: Router, path: &str, boundary: &str, body: &str) -> Response {
4049 app.oneshot(
4050 Request::builder()
4051 .method("POST")
4052 .uri(path)
4053 .header(
4054 header::CONTENT_TYPE,
4055 format!("multipart/form-data; boundary={boundary}"),
4056 )
4057 .body(Body::from(body.to_string()))
4058 .expect("request"),
4059 )
4060 .await
4061 .expect("route response")
4062 }
4063
4064 async fn get(app: Router, path: &str) -> Response {
4065 app.oneshot(
4066 Request::builder()
4067 .method("GET")
4068 .uri(path)
4069 .body(Body::empty())
4070 .expect("request"),
4071 )
4072 .await
4073 .expect("route response")
4074 }
4075
4076 async fn response_json(response: Response) -> Value {
4077 let bytes = to_bytes(response.into_body(), usize::MAX)
4078 .await
4079 .expect("body bytes");
4080 serde_json::from_slice(&bytes).expect("json body")
4081 }
4082
4083 async fn response_text(response: Response) -> String {
4084 let bytes = to_bytes(response.into_body(), usize::MAX)
4085 .await
4086 .expect("body bytes");
4087 String::from_utf8(bytes.to_vec()).expect("utf8 body")
4088 }
4089
4090 async fn response_bytes(response: Response) -> Vec<u8> {
4091 to_bytes(response.into_body(), usize::MAX)
4092 .await
4093 .expect("body bytes")
4094 .to_vec()
4095 }
4096
4097 async fn error_json(error: ServerError) -> (AxumStatusCode, Value) {
4098 let response = error.into_response();
4099 let status = response.status();
4100 (status, response_json(response).await)
4101 }
4102
4103 fn assert_openai_stream_error(body: &str, expected_message: &str) {
4104 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4105 assert!(
4106 body.contains("\"error\":{\"message\":\""),
4107 "stream failure should emit OpenAI error envelope: {body}"
4108 );
4109 assert!(
4110 body.contains(expected_message),
4111 "stream failure should include engine error message {expected_message:?}: {body}"
4112 );
4113 assert!(
4114 body.contains("\"type\":\"internal_server_error\""),
4115 "stream failure should use internal_server_error: {body}"
4116 );
4117 assert!(
4118 !body.contains("{\"error\":\""),
4119 "stream failure must not use legacy bare error payload: {body}"
4120 );
4121 }
4122
4123 fn chat_request(extra: Value) -> ChatCompletionsRequest {
4124 let mut value = json!({
4125 "model": "stub-model",
4126 "messages": [{"role": "user", "content": "hello"}],
4127 "max_tokens": 8
4128 });
4129 let obj = value.as_object_mut().unwrap();
4130 for (k, v) in extra.as_object().unwrap() {
4131 obj.insert(k.clone(), v.clone());
4132 }
4133 serde_json::from_value(value).expect("chat request")
4134 }
4135
4136 #[tokio::test]
4137 async fn route_health_includes_runtime_config_snapshot() {
4138 let response = get(router_with_stub("ok"), "/health").await;
4139 assert_eq!(response.status(), AxumStatusCode::OK);
4140 let body = response_json(response).await;
4141 assert_eq!(body["status"], "healthy");
4142 assert!(body["config"]["entries"].is_array(), "body: {body}");
4143 assert_eq!(body["auto_config"]["schema_version"], 1);
4144 assert!(body["auto_config"]["entries"].is_array(), "body: {body}");
4145 assert!(body["auto_config"]["admission"].is_object(), "body: {body}");
4146 assert_eq!(body["admission"]["schema_version"], 1);
4147 assert!(body["admission"]["effective_max_concurrent"].is_number());
4148 assert!(body["admission"]["queue_depth"].is_number());
4149 assert!(body["admission"]["active_prefill"].is_number());
4150 assert!(body["admission"]["active_decode"].is_number());
4151 assert!(body["admission"]["current_batch_size"].is_number());
4152 assert!(body["admission"]["rejected_requests_total"].is_number());
4153 assert!(body["admission"]["failed_requests_total"].is_number());
4154 assert!(body["admission"]["completed_requests_total"].is_number());
4155 assert!(body["admission"]["avg_queue_wait_time_ms"].is_number());
4156 assert!(body["scheduler"]["avg_wait_time_ms"].is_number());
4157 assert!(body["scheduler"]["scheduling_time_ms"].is_number());
4158 assert!(body["scheduler"]["model_execution_time_ms"].is_number());
4159 assert!(body["scheduler"]["iteration_lock_wait_time_ms"].is_number());
4160 assert!(
4161 body["auto_config"]["decisions"].is_array() || body["auto_config"]["error"].is_string(),
4162 "body: {body}"
4163 );
4164 }
4165
4166 #[tokio::test]
4167 async fn route_metrics_includes_admission_counters() {
4168 let response = get(router_with_stub("ok"), "/metrics").await;
4169 assert_eq!(response.status(), AxumStatusCode::OK);
4170 let body = response_text(response).await;
4171 for metric in [
4172 "ferrum_admission_effective_max_concurrent",
4173 "ferrum_admission_queue_depth",
4174 "ferrum_admission_active_prefill",
4175 "ferrum_admission_active_decode",
4176 "ferrum_admission_current_batch_size",
4177 "ferrum_admission_rejected_requests_total",
4178 "ferrum_admission_failed_requests_total",
4179 "ferrum_admission_completed_requests_total",
4180 ] {
4181 assert!(body.contains(metric), "missing {metric}:\n{body}");
4182 }
4183 }
4184
4185 #[tokio::test]
4186 async fn route_health_includes_engine_lora_metrics_snapshot() {
4187 let router = AxumServer::from_llm(Arc::new(StubLlm::with_lora_metrics(
4188 "ok",
4189 json!({
4190 "enabled": true,
4191 "adapter_count": 1,
4192 "active_cache_bindings": 0,
4193 "projection_applications": 7,
4194 "position": "real-inference",
4195 "source": "test-lora",
4196 }),
4197 )))
4198 .with_lora_adapters(
4199 "stub-model",
4200 vec![LoraAdapterModel::new(
4201 "sql",
4202 "stub-model:sql",
4203 "/tmp/sql-adapter",
4204 )],
4205 )
4206 .build_router();
4207 let response = get(router, "/health").await;
4208 assert_eq!(response.status(), AxumStatusCode::OK);
4209 let body = response_json(response).await;
4210 assert_eq!(body["lora"]["enabled"], true);
4211 assert_eq!(body["lora"]["adapter_count"], 1);
4212 assert_eq!(body["lora"]["projection_applications"], 7);
4213 assert_eq!(body["lora"]["position"], "real-inference");
4214 assert_eq!(body["lora"]["source"], "test-lora");
4215 }
4216
4217 #[tokio::test]
4218 async fn route_models_lists_loaded_stub_model() {
4219 let response = get(router_with_stub("ok"), "/v1/models").await;
4220 assert_eq!(response.status(), AxumStatusCode::OK);
4221 let body = response_json(response).await;
4222 assert_eq!(body["object"], "list");
4223 let data = body["data"].as_array().expect("models data array");
4224 assert_eq!(data.len(), 1, "body: {body}");
4225 assert_eq!(data[0]["id"], "stub-model");
4226 assert_eq!(data[0]["object"], "model");
4227 assert_eq!(data[0]["owned_by"], "ferrum");
4228 assert!(data[0]["created"].as_u64().unwrap_or_default() > 0);
4229 assert!(data[0]["permission"].as_array().unwrap().is_empty());
4230 assert!(data[0]["root"].is_null());
4231 assert!(data[0]["parent"].is_null());
4232 }
4233
4234 #[tokio::test]
4235 async fn route_models_lists_startup_lora_adapters() {
4236 let router = AxumServer::from_llm(Arc::new(StubLlm::new("ok")))
4237 .with_lora_adapters(
4238 "stub-model",
4239 vec![LoraAdapterModel::new(
4240 "sql",
4241 "stub-model:sql",
4242 "/tmp/sql-adapter",
4243 )],
4244 )
4245 .build_router();
4246 let response = get(router, "/v1/models").await;
4247 assert_eq!(response.status(), AxumStatusCode::OK);
4248 let body = response_json(response).await;
4249 let data = body["data"].as_array().expect("models data array");
4250 let ids: Vec<_> = data
4251 .iter()
4252 .map(|item| item["id"].as_str().unwrap_or_default())
4253 .collect();
4254 assert!(ids.contains(&"stub-model"), "body: {body}");
4255 assert!(ids.contains(&"stub-model:sql"), "body: {body}");
4256 let adapter = data
4257 .iter()
4258 .find(|item| item["id"] == "stub-model:sql")
4259 .expect("adapter model");
4260 assert_eq!(adapter["root"], "stub-model");
4261 assert_eq!(adapter["parent"], "stub-model");
4262 }
4263
4264 #[tokio::test]
4265 async fn route_chat_lora_adapter_maps_internal_request_to_base_model() {
4266 let (router, engine) = router_with_capturing_lora_llm();
4267 let response = post_json(
4268 router,
4269 "/v1/chat/completions",
4270 json!({
4271 "model": "qwen3:sql",
4272 "messages": [{"role": "user", "content": "Say hi"}],
4273 "max_tokens": 8,
4274 "temperature": 0.0
4275 }),
4276 )
4277 .await;
4278 assert_eq!(response.status(), AxumStatusCode::OK);
4279 let body = response_json(response).await;
4280 assert_eq!(body["model"], "qwen3:sql");
4281 let captured = engine.last_request();
4282 assert_eq!(captured.model_id, ModelId::new("qwen3"));
4283 assert_eq!(captured.metadata["ferrum_lora_adapter"], "sql");
4284 assert_eq!(captured.metadata["ferrum_lora_model_id"], "qwen3:sql");
4285 }
4286
4287 #[tokio::test]
4288 async fn route_chat_base_model_still_uses_base_path_with_lora_loaded() {
4289 let (router, engine) = router_with_capturing_lora_llm();
4290 let response = post_json(
4291 router,
4292 "/v1/chat/completions",
4293 json!({
4294 "model": "qwen3",
4295 "messages": [{"role": "user", "content": "Say hi"}],
4296 "max_tokens": 8,
4297 "temperature": 0.0
4298 }),
4299 )
4300 .await;
4301 assert_eq!(response.status(), AxumStatusCode::OK);
4302 let captured = engine.last_request();
4303 assert_eq!(captured.model_id, ModelId::new("qwen3"));
4304 assert!(!captured.metadata.contains_key("ferrum_lora_adapter"));
4305 }
4306
4307 #[tokio::test]
4308 async fn route_chat_unknown_lora_adapter_returns_openai_model_error() {
4309 let (router, _) = router_with_capturing_lora_llm();
4310 let response = post_json(
4311 router,
4312 "/v1/chat/completions",
4313 json!({
4314 "model": "qwen3:missing",
4315 "messages": [{"role": "user", "content": "Say hi"}],
4316 "max_tokens": 8
4317 }),
4318 )
4319 .await;
4320 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
4321 let body = response_json(response).await;
4322 assert_eq!(body["error"]["type"], "invalid_request_error");
4323 assert_eq!(body["error"]["param"], "model");
4324 assert!(
4325 body["error"]["message"]
4326 .as_str()
4327 .unwrap_or_default()
4328 .contains("unknown LoRA adapter model"),
4329 "body: {body}"
4330 );
4331 }
4332
4333 #[tokio::test]
4334 async fn route_models_without_engine_returns_empty_list() {
4335 let response = get(router_without_llm(), "/v1/models").await;
4336 assert_eq!(response.status(), AxumStatusCode::OK);
4337 let body = response_json(response).await;
4338 assert_eq!(body["object"], "list");
4339 assert!(body["data"].as_array().unwrap().is_empty(), "body: {body}");
4340 }
4341
4342 #[tokio::test]
4343 async fn route_basic_chat_contract_uses_stub_engine() {
4344 let response = post_json(
4345 router_with_stub("hello"),
4346 "/v1/chat/completions",
4347 json!({
4348 "model": "stub-model",
4349 "messages": [{"role": "user", "content": "Say hi"}],
4350 "max_tokens": 8,
4351 "temperature": 0.0
4352 }),
4353 )
4354 .await;
4355 assert_eq!(response.status(), AxumStatusCode::OK);
4356 let body = response_json(response).await;
4357 assert_eq!(body["object"], "chat.completion");
4358 assert_eq!(body["choices"][0]["message"]["role"], "assistant");
4359 assert_eq!(body["choices"][0]["message"]["content"], "hello");
4360 assert_eq!(body["usage"]["prompt_tokens"], 7);
4361 assert_eq!(body["usage"]["completion_tokens"], 2);
4362 }
4363
4364 #[tokio::test]
4365 async fn route_chat_serializes_structured_tool_call_response() {
4366 let response = post_json(
4367 router_with_stub_api_response(
4368 "",
4369 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
4370 message: ferrum_types::ApiChatMessage {
4371 role: ferrum_types::ApiMessageRole::Assistant,
4372 content: String::new(),
4373 name: None,
4374 tool_calls: vec![ferrum_types::ApiToolCall {
4375 id: "call_1".to_string(),
4376 tool_type: "function".to_string(),
4377 function: ferrum_types::ApiFunctionCall {
4378 name: "weather".to_string(),
4379 arguments: "{\"city\":\"Paris\"}".to_string(),
4380 },
4381 }],
4382 tool_call_id: None,
4383 function_call: None,
4384 },
4385 finish_reason: Some("tool_calls".to_string()),
4386 }),
4387 ),
4388 "/v1/chat/completions",
4389 json!({
4390 "model": "stub-model",
4391 "messages": [{"role": "user", "content": "Use the weather tool."}],
4392 "tools": [{
4393 "type": "function",
4394 "function": {
4395 "name": "weather",
4396 "parameters": {
4397 "type": "object",
4398 "properties": {"city": {"type": "string"}},
4399 "required": ["city"]
4400 }
4401 }
4402 }],
4403 "tool_choice": "auto"
4404 }),
4405 )
4406 .await;
4407 assert_eq!(response.status(), AxumStatusCode::OK);
4408 let body = response_json(response).await;
4409 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4410 assert_eq!(
4411 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4412 "weather"
4413 );
4414 assert_eq!(
4415 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4416 "{\"city\":\"Paris\"}"
4417 );
4418 }
4419
4420 #[tokio::test]
4421 async fn route_chat_serializes_generated_tool_call_json_when_engine_returns_text_only() {
4422 let response = post_json(
4423 router_with_stub(
4424 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
4425 ),
4426 "/v1/chat/completions",
4427 json!({
4428 "model": "stub-model",
4429 "messages": [{"role": "user", "content": "Use the weather tool."}],
4430 "tools": [{
4431 "type": "function",
4432 "function": {
4433 "name": "weather",
4434 "parameters": {
4435 "type": "object",
4436 "properties": {"city": {"type": "string"}},
4437 "required": ["city"]
4438 }
4439 }
4440 }],
4441 "tool_choice": "auto"
4442 }),
4443 )
4444 .await;
4445 assert_eq!(response.status(), AxumStatusCode::OK);
4446 let body = response_json(response).await;
4447 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4448 assert_eq!(body["choices"][0]["message"]["content"], "");
4449 assert_eq!(
4450 body["choices"][0]["message"]["tool_calls"][0]["id"],
4451 "call_1"
4452 );
4453 assert_eq!(
4454 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4455 "weather"
4456 );
4457 assert_eq!(
4458 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4459 "{\"city\":\"Paris\"}"
4460 );
4461 }
4462
4463 #[tokio::test]
4464 async fn route_chat_serializes_qwen3_function_parameters_tool_json() {
4465 let response = post_json(
4466 router_with_stub(
4467 r#"{"function":"get_weather","parameters":{"city":"北京","unit":"c"}}"#,
4468 ),
4469 "/v1/chat/completions",
4470 json!({
4471 "model": "stub-model",
4472 "messages": [{"role": "user", "content": "北京现在天气怎么样?"}],
4473 "tools": [{
4474 "type": "function",
4475 "function": {
4476 "name": "get_weather",
4477 "parameters": {
4478 "type": "object",
4479 "properties": {
4480 "city": {"type": "string"},
4481 "unit": {"type": "string", "enum": ["c", "f"]}
4482 },
4483 "required": ["city"]
4484 }
4485 }
4486 }],
4487 "tool_choice": "auto"
4488 }),
4489 )
4490 .await;
4491 assert_eq!(response.status(), AxumStatusCode::OK);
4492 let body = response_json(response).await;
4493 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4494 assert_eq!(body["choices"][0]["message"]["content"], "");
4495 assert_eq!(
4496 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4497 "get_weather"
4498 );
4499 assert_eq!(
4500 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4501 "{\"city\":\"北京\",\"unit\":\"c\"}"
4502 );
4503 }
4504
4505 #[tokio::test]
4506 async fn route_chat_parses_tool_call_from_reasoning_before_fake_tool_result_content() {
4507 let response = post_json(
4508 router_with_stub(
4509 "kaza\n\
4510 {\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
4511 </think>\n\
4512 {\"name\":\"get_weather\",\"content\":{\"temperature\":25,\"condition\":\"晴\"}}\n\
4513 {\"temperature\":25,\"condition\":\"晴\"}",
4514 ),
4515 "/v1/chat/completions",
4516 json!({
4517 "model": "stub-model",
4518 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用工具。"}],
4519 "tools": [{
4520 "type": "function",
4521 "function": {
4522 "name": "get_weather",
4523 "parameters": {
4524 "type": "object",
4525 "properties": {
4526 "city": {"type": "string"},
4527 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
4528 },
4529 "required": ["city"]
4530 }
4531 }
4532 }]
4533 }),
4534 )
4535 .await;
4536 assert_eq!(response.status(), AxumStatusCode::OK);
4537 let body = response_json(response).await;
4538 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4539 assert_eq!(body["choices"][0]["message"]["content"], "");
4540 assert_eq!(
4541 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4542 "get_weather"
4543 );
4544 assert_eq!(
4545 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4546 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
4547 );
4548 }
4549
4550 #[tokio::test]
4551 async fn route_chat_prefers_reasoning_tool_call_over_empty_visible_arguments() {
4552 let response = post_json(
4553 router_with_stub(
4554 "{\"name\":\"get_weather\",\"arguments\":{\"city\":\"北京\",\"unit\":\"celsius\"}}\n\
4555 </think>\n\
4556 {\"name\":\"get_weather\",\"arguments\":{}}",
4557 ),
4558 "/v1/chat/completions",
4559 json!({
4560 "model": "stub-model",
4561 "messages": [{"role": "user", "content": "北京现在天气怎么样?请先调用 get_weather 工具。"}],
4562 "tools": [{
4563 "type": "function",
4564 "function": {
4565 "name": "get_weather",
4566 "parameters": {
4567 "type": "object",
4568 "properties": {
4569 "city": {"type": "string"},
4570 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
4571 },
4572 "required": ["city"]
4573 }
4574 }
4575 }]
4576 }),
4577 )
4578 .await;
4579 assert_eq!(response.status(), AxumStatusCode::OK);
4580 let body = response_json(response).await;
4581 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4582 assert_eq!(
4583 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4584 "get_weather"
4585 );
4586 assert_eq!(
4587 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4588 "{\"city\":\"北京\",\"unit\":\"celsius\"}"
4589 );
4590 }
4591
4592 #[tokio::test]
4593 async fn route_chat_honors_specific_tool_choice_for_generated_tool_call_json() {
4594 let response = post_json(
4595 router_with_stub(r#"{"name":"weather","arguments":{"city":"Paris"}}"#),
4596 "/v1/chat/completions",
4597 json!({
4598 "model": "stub-model",
4599 "messages": [{"role": "user", "content": "Use the selected tool."}],
4600 "tools": [
4601 {
4602 "type": "function",
4603 "function": {"name": "weather", "parameters": {"type": "object"}}
4604 },
4605 {
4606 "type": "function",
4607 "function": {"name": "calendar", "parameters": {"type": "object"}}
4608 }
4609 ],
4610 "tool_choice": {
4611 "type": "function",
4612 "function": {"name": "weather"}
4613 }
4614 }),
4615 )
4616 .await;
4617 assert_eq!(response.status(), AxumStatusCode::OK);
4618 let body = response_json(response).await;
4619 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4620 assert_eq!(
4621 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4622 "weather"
4623 );
4624
4625 let response = post_json(
4626 router_with_stub(r#"{"name":"calendar","arguments":{}}"#),
4627 "/v1/chat/completions",
4628 json!({
4629 "model": "stub-model",
4630 "messages": [{"role": "user", "content": "Use the selected tool."}],
4631 "tools": [
4632 {
4633 "type": "function",
4634 "function": {"name": "weather", "parameters": {"type": "object"}}
4635 },
4636 {
4637 "type": "function",
4638 "function": {"name": "calendar", "parameters": {"type": "object"}}
4639 }
4640 ],
4641 "tool_choice": {
4642 "type": "function",
4643 "function": {"name": "weather"}
4644 }
4645 }),
4646 )
4647 .await;
4648 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
4649 let body = response_json(response).await;
4650 assert_eq!(body["error"]["param"], "tool_choice");
4651 assert_eq!(body["error"]["type"], "invalid_request_error");
4652 }
4653
4654 #[tokio::test]
4655 async fn route_chat_specific_tool_choice_wraps_generated_arguments() {
4656 let response = post_json(
4657 router_with_stub(r#"{"city":"Paris"}"#),
4658 "/v1/chat/completions",
4659 json!({
4660 "model": "stub-model",
4661 "messages": [{"role": "user", "content": "Use the selected tool."}],
4662 "tools": [{
4663 "type": "function",
4664 "function": {
4665 "name": "weather",
4666 "parameters": {
4667 "type": "object",
4668 "properties": {"city": {"type": "string"}},
4669 "required": ["city"]
4670 }
4671 }
4672 }],
4673 "tool_choice": {
4674 "type": "function",
4675 "function": {"name": "weather"}
4676 }
4677 }),
4678 )
4679 .await;
4680 assert_eq!(response.status(), AxumStatusCode::OK);
4681 let body = response_json(response).await;
4682 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4683 assert_eq!(body["choices"][0]["message"]["content"], "");
4684 assert_eq!(
4685 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4686 "weather"
4687 );
4688 assert_eq!(
4689 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4690 "{\"city\":\"Paris\"}"
4691 );
4692 }
4693
4694 #[tokio::test]
4695 async fn route_chat_tool_choice_none_keeps_generated_tool_json_as_content() {
4696 let generated = r#"{"name":"weather","arguments":{"city":"Paris"}}"#;
4697 let response = post_json(
4698 router_with_stub(generated),
4699 "/v1/chat/completions",
4700 json!({
4701 "model": "stub-model",
4702 "messages": [{"role": "user", "content": "Do not use tools."}],
4703 "tools": [{
4704 "type": "function",
4705 "function": {"name": "weather", "parameters": {"type": "object"}}
4706 }],
4707 "tool_choice": "none"
4708 }),
4709 )
4710 .await;
4711 assert_eq!(response.status(), AxumStatusCode::OK);
4712 let body = response_json(response).await;
4713 assert_eq!(body["choices"][0]["finish_reason"], "stop");
4714 assert_eq!(body["choices"][0]["message"]["content"], generated);
4715 assert!(body["choices"][0]["message"]["tool_calls"].is_null());
4716 }
4717
4718 #[tokio::test]
4719 async fn route_chat_tool_choice_required_wraps_generated_arguments() {
4720 let response = post_json(
4721 router_with_stub(r#"{"city":"Paris"}"#),
4722 "/v1/chat/completions",
4723 json!({
4724 "model": "stub-model",
4725 "messages": [{"role": "user", "content": "Use a tool."}],
4726 "tools": [{
4727 "type": "function",
4728 "function": {
4729 "name": "weather",
4730 "parameters": {
4731 "type": "object",
4732 "properties": {"city": {"type": "string"}},
4733 "required": ["city"]
4734 }
4735 }
4736 }],
4737 "tool_choice": "required"
4738 }),
4739 )
4740 .await;
4741 assert_eq!(response.status(), AxumStatusCode::OK);
4742 let body = response_json(response).await;
4743 assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
4744 assert_eq!(body["choices"][0]["message"]["content"], "");
4745 assert_eq!(
4746 body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
4747 "weather"
4748 );
4749 assert_eq!(
4750 body["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
4751 "{\"city\":\"Paris\"}"
4752 );
4753 }
4754
4755 #[tokio::test]
4756 async fn route_chat_tool_choice_required_errors_without_valid_tool_call() {
4757 let response = post_json(
4758 router_with_stub("plain answer"),
4759 "/v1/chat/completions",
4760 json!({
4761 "model": "stub-model",
4762 "messages": [{"role": "user", "content": "Use a tool."}],
4763 "tools": [{
4764 "type": "function",
4765 "function": {"name": "weather", "parameters": {"type": "object"}}
4766 }],
4767 "tool_choice": "required"
4768 }),
4769 )
4770 .await;
4771 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
4772 let body = response_json(response).await;
4773 assert_eq!(body["error"]["type"], "invalid_request_error");
4774 assert_eq!(body["error"]["param"], "tool_choice");
4775 assert!(
4776 body["error"]["message"]
4777 .as_str()
4778 .is_some_and(|message| message.contains("required tool_choice")),
4779 "body: {body}"
4780 );
4781 }
4782
4783 #[tokio::test]
4784 async fn route_streaming_chat_serializes_generated_tool_call_delta() {
4785 let response = post_json(
4786 router_with_stub(
4787 r#"{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"weather","arguments":{"city":"Paris"}}}]}"#,
4788 ),
4789 "/v1/chat/completions",
4790 json!({
4791 "model": "stub-model",
4792 "messages": [{"role": "user", "content": "Use the weather tool."}],
4793 "stream": true,
4794 "tools": [{
4795 "type": "function",
4796 "function": {
4797 "name": "weather",
4798 "parameters": {
4799 "type": "object",
4800 "properties": {"city": {"type": "string"}},
4801 "required": ["city"]
4802 }
4803 }
4804 }],
4805 "tool_choice": "auto"
4806 }),
4807 )
4808 .await;
4809 assert_eq!(response.status(), AxumStatusCode::OK);
4810 let body = response_text(response).await;
4811 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4812 assert!(
4813 body.contains(r#""finish_reason":"tool_calls""#),
4814 "stream should finish with tool_calls: {body}"
4815 );
4816 assert!(
4817 body.contains(r#""tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather""#),
4818 "stream should emit OpenAI tool_calls delta with index: {body}"
4819 );
4820 assert!(
4821 body.contains(r#""arguments":"{\"city\":\"Paris\"}""#),
4822 "tool arguments should be serialized as JSON string: {body}"
4823 );
4824 assert!(
4825 !body.contains(r#""content":"{\"tool_calls\""#),
4826 "raw tool-call JSON should not be streamed as assistant content: {body}"
4827 );
4828 }
4829
4830 #[tokio::test]
4831 async fn route_streaming_chat_serializes_qwen3_function_parameters_tool_delta() {
4832 let response = post_json(
4833 router_with_stub(
4834 r#"{"function":"get_weather","parameters":{"city":"深圳","unit":"c"}}"#,
4835 ),
4836 "/v1/chat/completions",
4837 json!({
4838 "model": "stub-model",
4839 "messages": [{"role": "user", "content": "深圳天气?"}],
4840 "stream": true,
4841 "tools": [{
4842 "type": "function",
4843 "function": {
4844 "name": "get_weather",
4845 "parameters": {
4846 "type": "object",
4847 "properties": {
4848 "city": {"type": "string"},
4849 "unit": {"type": "string", "enum": ["c", "f"]}
4850 },
4851 "required": ["city"]
4852 }
4853 }
4854 }],
4855 "tool_choice": "auto"
4856 }),
4857 )
4858 .await;
4859 assert_eq!(response.status(), AxumStatusCode::OK);
4860 let body = response_text(response).await;
4861 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4862 assert!(
4863 body.contains(r#""finish_reason":"tool_calls""#),
4864 "stream should finish with tool_calls: {body}"
4865 );
4866 assert!(
4867 body.contains(r#""function":{"name":"get_weather","arguments":"{\"city\":\"深圳\",\"unit\":\"c\"}"}"#),
4868 "stream should emit parsed Qwen3 function parameters as tool args: {body}"
4869 );
4870 assert!(
4871 !body.contains(r#""content":"{\"function\""#),
4872 "raw Qwen3 tool JSON should not leak as assistant content: {body}"
4873 );
4874 }
4875
4876 #[tokio::test]
4877 async fn route_streaming_chat_honors_specific_tool_choice_for_generated_tool_call_delta() {
4878 let request = |generated: &'static str| {
4879 post_json(
4880 router_with_stub(generated),
4881 "/v1/chat/completions",
4882 json!({
4883 "model": "stub-model",
4884 "messages": [{"role": "user", "content": "Use the selected tool."}],
4885 "stream": true,
4886 "tools": [
4887 {
4888 "type": "function",
4889 "function": {"name": "weather", "parameters": {"type": "object"}}
4890 },
4891 {
4892 "type": "function",
4893 "function": {"name": "calendar", "parameters": {"type": "object"}}
4894 }
4895 ],
4896 "tool_choice": {
4897 "type": "function",
4898 "function": {"name": "weather"}
4899 }
4900 }),
4901 )
4902 };
4903
4904 let response = request(r#"{"name":"weather","arguments":{"city":"Paris"}}"#).await;
4905 assert_eq!(response.status(), AxumStatusCode::OK);
4906 let body = response_text(response).await;
4907 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4908 assert!(
4909 body.contains(r#""finish_reason":"tool_calls""#),
4910 "selected tool should finish with tool_calls: {body}"
4911 );
4912 assert!(
4913 body.contains(r#""function":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#),
4914 "selected tool should stream as tool_calls delta: {body}"
4915 );
4916
4917 let response = request(r#"{"name":"calendar","arguments":{}}"#).await;
4918 assert_eq!(response.status(), AxumStatusCode::OK);
4919 let body = response_text(response).await;
4920 assert!(
4921 body.contains(
4922 r#""error":{"message":"model output did not satisfy required tool_choice""#
4923 ),
4924 "selected-tool stream should reject unselected tool output: {body}"
4925 );
4926 assert!(
4927 !body.contains(r#""finish_reason":"tool_calls""#),
4928 "unselected tool JSON must not become tool_calls: {body}"
4929 );
4930 }
4931
4932 #[tokio::test]
4933 async fn route_streaming_chat_prefers_chunk_api_response_for_tool_delta() {
4934 let response = post_json(
4935 router_with_stub_api_response(
4936 "raw text that should not stream",
4937 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
4938 message: ferrum_types::ApiChatMessage {
4939 role: ferrum_types::ApiMessageRole::Assistant,
4940 content: String::new(),
4941 name: None,
4942 tool_calls: vec![ferrum_types::ApiToolCall {
4943 id: "call_1".to_string(),
4944 tool_type: "function".to_string(),
4945 function: ferrum_types::ApiFunctionCall {
4946 name: "weather".to_string(),
4947 arguments: "{\"city\":\"Paris\"}".to_string(),
4948 },
4949 }],
4950 tool_call_id: None,
4951 function_call: None,
4952 },
4953 finish_reason: Some("tool_calls".to_string()),
4954 }),
4955 ),
4956 "/v1/chat/completions",
4957 json!({
4958 "model": "stub-model",
4959 "messages": [{"role": "user", "content": "Use the weather tool."}],
4960 "stream": true,
4961 "tools": [{
4962 "type": "function",
4963 "function": {"name": "weather", "parameters": {"type": "object"}}
4964 }],
4965 "tool_choice": "auto"
4966 }),
4967 )
4968 .await;
4969 assert_eq!(response.status(), AxumStatusCode::OK);
4970 let body = response_text(response).await;
4971 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
4972 assert!(
4973 body.contains(r#""finish_reason":"tool_calls""#),
4974 "stream should finish with tool_calls: {body}"
4975 );
4976 assert!(
4977 body.contains(r#""tool_calls":[{"index":0,"id":"call_1""#),
4978 "stream should emit tool_calls from chunk api_response: {body}"
4979 );
4980 assert!(
4981 !body.contains("raw text that should not stream"),
4982 "structured api_response should suppress raw generated text in tool-call stream: {body}"
4983 );
4984 }
4985
4986 #[tokio::test]
4987 async fn route_streaming_chat_tool_choice_required_errors_without_leaking_content() {
4988 let response = post_json(
4989 router_with_stub("plain answer"),
4990 "/v1/chat/completions",
4991 json!({
4992 "model": "stub-model",
4993 "messages": [{"role": "user", "content": "Use a tool."}],
4994 "stream": true,
4995 "tools": [{
4996 "type": "function",
4997 "function": {"name": "weather", "parameters": {"type": "object"}}
4998 }],
4999 "tool_choice": "required"
5000 }),
5001 )
5002 .await;
5003 assert_eq!(response.status(), AxumStatusCode::OK);
5004 let body = response_text(response).await;
5005 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
5006 assert!(
5007 body.contains(
5008 r#""error":{"message":"model output did not satisfy required tool_choice""#
5009 ),
5010 "stream should emit OpenAI error envelope: {body}"
5011 );
5012 assert!(
5013 body.contains(r#""type":"invalid_request_error""#),
5014 "stream should use invalid_request_error: {body}"
5015 );
5016 assert!(
5017 body.contains(r#""param":"tool_choice""#),
5018 "stream should include tool_choice param: {body}"
5019 );
5020 assert!(
5021 !body.contains(r#""content":"plain answer""#),
5022 "required stream must not leak invalid content before validation: {body}"
5023 );
5024 }
5025
5026 #[tokio::test]
5027 async fn route_streaming_chat_tool_request_falls_back_to_content_when_no_tool_call() {
5028 let response = post_json(
5029 router_with_stub("plain answer"),
5030 "/v1/chat/completions",
5031 json!({
5032 "model": "stub-model",
5033 "messages": [{"role": "user", "content": "Use the weather tool if needed."}],
5034 "stream": true,
5035 "tools": [{
5036 "type": "function",
5037 "function": {"name": "weather", "parameters": {"type": "object"}}
5038 }],
5039 "tool_choice": "auto"
5040 }),
5041 )
5042 .await;
5043 assert_eq!(response.status(), AxumStatusCode::OK);
5044 let body = response_text(response).await;
5045 assert!(
5046 body.contains(r#""content":"plain answer""#),
5047 "plain content should still stream when no tool call is generated: {body}"
5048 );
5049 assert!(
5050 body.contains(r#""finish_reason":"stop""#),
5051 "plain content should keep normal finish reason: {body}"
5052 );
5053 assert!(
5054 !body.contains(r#""tool_calls""#),
5055 "fallback content should not synthesize tool_calls: {body}"
5056 );
5057 }
5058
5059 #[tokio::test]
5060 async fn route_streaming_chat_serializes_generated_legacy_function_call_delta() {
5061 let response = post_json(
5062 router_with_stub(
5063 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
5064 ),
5065 "/v1/chat/completions",
5066 json!({
5067 "model": "stub-model",
5068 "messages": [{"role": "user", "content": "Use the weather function."}],
5069 "stream": true,
5070 "functions": [{
5071 "name": "weather",
5072 "parameters": {
5073 "type": "object",
5074 "properties": {"city": {"type": "string"}},
5075 "required": ["city"]
5076 }
5077 }],
5078 "function_call": "auto"
5079 }),
5080 )
5081 .await;
5082 assert_eq!(response.status(), AxumStatusCode::OK);
5083 let body = response_text(response).await;
5084 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5085 assert!(
5086 body.contains(r#""finish_reason":"function_call""#),
5087 "stream should finish with function_call: {body}"
5088 );
5089 assert!(
5090 body.contains(
5091 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
5092 ),
5093 "stream should emit OpenAI legacy function_call delta: {body}"
5094 );
5095 assert!(
5096 !body.contains(r#""content":"{\"function_call\""#),
5097 "raw function-call JSON should not be streamed as assistant content: {body}"
5098 );
5099 }
5100
5101 #[tokio::test]
5102 async fn route_streaming_chat_honors_specific_legacy_function_call_delta() {
5103 let request = |generated: &'static str| {
5104 post_json(
5105 router_with_stub(generated),
5106 "/v1/chat/completions",
5107 json!({
5108 "model": "stub-model",
5109 "messages": [{"role": "user", "content": "Use the selected function."}],
5110 "stream": true,
5111 "functions": [
5112 {"name": "weather", "parameters": {"type": "object"}},
5113 {"name": "calendar", "parameters": {"type": "object"}}
5114 ],
5115 "function_call": {"name": "weather"}
5116 }),
5117 )
5118 };
5119
5120 let response =
5121 request(r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#).await;
5122 assert_eq!(response.status(), AxumStatusCode::OK);
5123 let body = response_text(response).await;
5124 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5125 assert!(
5126 body.contains(r#""finish_reason":"function_call""#),
5127 "selected function should finish with function_call: {body}"
5128 );
5129 assert!(
5130 body.contains(
5131 r#""function_call":{"name":"weather","arguments":"{\"city\":\"Paris\"}"}"#
5132 ),
5133 "selected function should stream as function_call delta: {body}"
5134 );
5135
5136 let response = request(r#"{"function_call":{"name":"calendar","arguments":{}}}"#).await;
5137 assert_eq!(response.status(), AxumStatusCode::OK);
5138 let body = response_text(response).await;
5139 assert!(
5140 body.contains(
5141 r#""content":"{\"function_call\":{\"name\":\"calendar\",\"arguments\":{}}}""#
5142 ),
5143 "unselected function JSON should stream as ordinary content: {body}"
5144 );
5145 assert!(
5146 body.contains(r#""finish_reason":"stop""#),
5147 "unselected function JSON should keep normal stop finish: {body}"
5148 );
5149 assert!(
5150 !body.contains(r#""finish_reason":"function_call""#),
5151 "unselected function JSON must not become function_call: {body}"
5152 );
5153 }
5154
5155 #[tokio::test]
5156 async fn route_chat_serializes_generated_legacy_function_call_when_engine_returns_text_only() {
5157 let response = post_json(
5158 router_with_stub(
5159 r#"{"function_call":{"name":"weather","arguments":{"city":"Paris"}}}"#,
5160 ),
5161 "/v1/chat/completions",
5162 json!({
5163 "model": "stub-model",
5164 "messages": [{"role": "user", "content": "Use the weather function."}],
5165 "functions": [{
5166 "name": "weather",
5167 "parameters": {
5168 "type": "object",
5169 "properties": {"city": {"type": "string"}},
5170 "required": ["city"]
5171 }
5172 }],
5173 "function_call": "auto"
5174 }),
5175 )
5176 .await;
5177 assert_eq!(response.status(), AxumStatusCode::OK);
5178 let body = response_json(response).await;
5179 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
5180 assert_eq!(body["choices"][0]["message"]["content"], "");
5181 assert_eq!(
5182 body["choices"][0]["message"]["function_call"]["name"],
5183 "weather"
5184 );
5185 assert_eq!(
5186 body["choices"][0]["message"]["function_call"]["arguments"],
5187 "{\"city\":\"Paris\"}"
5188 );
5189 }
5190
5191 #[tokio::test]
5192 async fn route_chat_serializes_legacy_function_call_response() {
5193 let response = post_json(
5194 router_with_stub_api_response(
5195 "",
5196 ferrum_types::ApiResponse::Chat(ferrum_types::ApiChatResponse {
5197 message: ferrum_types::ApiChatMessage {
5198 role: ferrum_types::ApiMessageRole::Assistant,
5199 content: String::new(),
5200 name: None,
5201 tool_calls: vec![],
5202 tool_call_id: None,
5203 function_call: Some(ferrum_types::ApiFunctionCall {
5204 name: "weather".to_string(),
5205 arguments: "{\"city\":\"Paris\"}".to_string(),
5206 }),
5207 },
5208 finish_reason: Some("function_call".to_string()),
5209 }),
5210 ),
5211 "/v1/chat/completions",
5212 json!({
5213 "model": "stub-model",
5214 "messages": [{"role": "user", "content": "Use the weather function."}],
5215 "functions": [{
5216 "name": "weather",
5217 "parameters": {
5218 "type": "object",
5219 "properties": {"city": {"type": "string"}},
5220 "required": ["city"]
5221 }
5222 }],
5223 "function_call": "auto"
5224 }),
5225 )
5226 .await;
5227 assert_eq!(response.status(), AxumStatusCode::OK);
5228 let body = response_json(response).await;
5229 assert_eq!(body["choices"][0]["finish_reason"], "function_call");
5230 assert_eq!(
5231 body["choices"][0]["message"]["function_call"]["name"],
5232 "weather"
5233 );
5234 assert_eq!(
5235 body["choices"][0]["message"]["function_call"]["arguments"],
5236 "{\"city\":\"Paris\"}"
5237 );
5238 }
5239
5240 #[tokio::test]
5241 async fn route_streaming_chat_include_usage_contract() {
5242 let response = post_json(
5243 router_with_stub("ok"),
5244 "/v1/chat/completions",
5245 json!({
5246 "model": "stub-model",
5247 "messages": [{"role": "user", "content": "Say ok"}],
5248 "stream": true,
5249 "stream_options": {"include_usage": true}
5250 }),
5251 )
5252 .await;
5253 assert_eq!(response.status(), AxumStatusCode::OK);
5254 let body = response_text(response).await;
5255 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5256 assert!(
5257 body.contains("\"object\":\"chat.completion.chunk\""),
5258 "missing chat chunk: {body}"
5259 );
5260 assert!(
5261 body.contains("\"usage\":{\"prompt_tokens\""),
5262 "missing final usage chunk: {body}"
5263 );
5264 assert!(
5265 body.contains("\"choices\":[],\"usage\""),
5266 "usage should be emitted as a separate chunk: {body}"
5267 );
5268 assert!(
5269 body.contains("\"prompt_tokens\":5"),
5270 "stream usage should come from engine token usage: {body}"
5271 );
5272 }
5273
5274 #[tokio::test]
5275 async fn route_streaming_chat_waits_for_separate_final_usage_at_max_tokens() {
5276 let response = post_json(
5277 router_with_stub_separate_final_stream_chunk(&["he", "llo"]),
5278 "/v1/chat/completions",
5279 json!({
5280 "model": "stub-model",
5281 "messages": [{"role": "user", "content": "Say hello"}],
5282 "max_tokens": 2,
5283 "stream": true,
5284 "stream_options": {"include_usage": true}
5285 }),
5286 )
5287 .await;
5288 assert_eq!(response.status(), AxumStatusCode::OK);
5289 let body = response_text(response).await;
5290 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
5291 assert!(
5292 body.contains("\"content\":\"he\""),
5293 "missing first chunk: {body}"
5294 );
5295 assert!(
5296 body.contains("\"content\":\"llo\""),
5297 "missing second chunk: {body}"
5298 );
5299 assert!(
5300 body.contains("\"choices\":[],\"usage\""),
5301 "missing separate usage chunk from final engine chunk: {body}"
5302 );
5303 assert!(
5304 body.contains("\"prompt_tokens\":5"),
5305 "stream usage should come from engine final usage: {body}"
5306 );
5307 }
5308
5309 #[tokio::test]
5310 async fn route_rejects_multimodal_content_with_400() {
5311 let response = post_json(
5312 router_with_stub("unused"),
5313 "/v1/chat/completions",
5314 json!({
5315 "model": "stub-model",
5316 "messages": [{
5317 "role": "user",
5318 "content": [
5319 {"type": "text", "text": "describe this"},
5320 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
5321 ]
5322 }]
5323 }),
5324 )
5325 .await;
5326 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5327 let body = response_json(response).await;
5328 assert_eq!(body["error"]["type"], "invalid_request_error");
5329 assert!(body["error"]["message"]
5330 .as_str()
5331 .unwrap()
5332 .contains("invalid chat completions request"));
5333 }
5334
5335 #[tokio::test]
5336 async fn route_chat_invalid_json_maps_to_openai_error() {
5337 let response = post_raw_json(router_with_stub("unused"), "/v1/chat/completions", "{").await;
5338 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5339 let body = response_json(response).await;
5340 assert_eq!(body["error"]["type"], "invalid_request_error");
5341 assert_eq!(body["error"]["param"], Value::Null);
5342 assert!(body["error"]["message"]
5343 .as_str()
5344 .unwrap()
5345 .contains("invalid chat completions request"));
5346 }
5347
5348 #[tokio::test]
5349 async fn route_rejects_logit_bias_with_openai_error_param() {
5350 let response = post_json(
5351 router_with_stub("unused"),
5352 "/v1/chat/completions",
5353 json!({
5354 "model": "stub-model",
5355 "messages": [{"role": "user", "content": "hello"}],
5356 "logit_bias": {"1": 42.0}
5357 }),
5358 )
5359 .await;
5360 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5361 let body = response_json(response).await;
5362 assert_eq!(body["error"]["type"], "invalid_request_error");
5363 assert_eq!(body["error"]["param"], "logit_bias");
5364 }
5365
5366 #[tokio::test]
5367 async fn route_tool_request_reaches_engine_structured_boundary() {
5368 let (router, engine) = router_with_capturing_llm();
5369 let response = post_json(
5370 router,
5371 "/v1/chat/completions",
5372 json!({
5373 "model": "qwen3",
5374 "messages": [
5375 {"role": "user", "content": "Use the weather tool."},
5376 {
5377 "role": "assistant",
5378 "content": null,
5379 "tool_calls": [{
5380 "id": "call_1",
5381 "type": "function",
5382 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
5383 }]
5384 },
5385 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
5386 ],
5387 "tools": [{
5388 "type": "function",
5389 "function": {
5390 "name": "weather",
5391 "description": "Get weather",
5392 "parameters": {
5393 "type": "object",
5394 "properties": {"city": {"type": "string"}},
5395 "required": ["city"]
5396 }
5397 }
5398 }],
5399 "tool_choice": "auto",
5400 "functions": [{
5401 "name": "legacy_weather",
5402 "parameters": {"type": "object", "properties": {}}
5403 }],
5404 "function_call": "auto"
5405 }),
5406 )
5407 .await;
5408 assert_eq!(response.status(), AxumStatusCode::OK);
5409
5410 let request = engine.last_request();
5411 assert!(request.prompt.contains("\"tools\":[{"));
5412 assert!(request.prompt.contains("\"type\":\"function\""));
5413 assert!(request.prompt.contains("\"name\":\"weather\""));
5414 assert!(request.prompt.contains("<|im_start|>assistant\n{"));
5415 assert!(request.prompt.contains("\"tool_calls\":[{"));
5416 assert!(request.prompt.contains("\"id\":\"call_1\""));
5417 assert!(request.prompt.contains("<|im_start|>tool\nsunny<|im_end|>"));
5418 assert_eq!(
5419 request.metadata["openai_tools"][0]["function"]["name"],
5420 "weather"
5421 );
5422 assert_eq!(request.metadata["openai_tool_choice"], "auto");
5423 assert_eq!(
5424 request.metadata["openai_legacy_functions"][0]["name"],
5425 "legacy_weather"
5426 );
5427 assert_eq!(request.metadata["openai_legacy_function_call"], "auto");
5428 let Some(ferrum_types::ApiRequest::Chat(api)) = request.api_request.as_ref() else {
5429 panic!("expected structured chat api_request");
5430 };
5431 assert_eq!(api.messages.len(), 3);
5432 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
5433 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
5434 assert_eq!(api.tools[0].function.name, "weather");
5435 assert_eq!(api.legacy_functions[0].name, "legacy_weather");
5436 assert_eq!(
5437 api.messages[1].tool_calls[0].function.arguments,
5438 "{\"city\":\"Paris\"}"
5439 );
5440 }
5441
5442 #[tokio::test]
5443 async fn route_tool_request_prefers_model_chat_template() {
5444 let template = ModelChatTemplate::new(
5445 "{% if tools %}<tools>{% for tool in tools %}{{ tool.function.name }}{% endfor %}</tools>{% endif %}{% for message in messages %}[{{ message.role }}]{{ message.content }}{% if message.tool_calls %}{% for tool_call in message.tool_calls %}<tool_call>{{ tool_call.function.name }}:{{ tool_call.function.arguments }}</tool_call>{% endfor %}{% endif %}{% if message.tool_call_id %}<tool_response id=\"{{ message.tool_call_id }}\">{{ message.content }}</tool_response>{% endif %}{% endfor %}{% if add_generation_prompt %}[assistant]{% endif %}",
5446 "tool-template",
5447 );
5448 let (router, engine) = router_with_capturing_llm_and_template(template);
5449 let response = post_json(
5450 router,
5451 "/v1/chat/completions",
5452 json!({
5453 "model": "served-alias",
5454 "messages": [
5455 {"role": "user", "content": "Use the weather tool."},
5456 {
5457 "role": "assistant",
5458 "content": null,
5459 "tool_calls": [{
5460 "id": "call_1",
5461 "type": "function",
5462 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
5463 }]
5464 },
5465 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
5466 ],
5467 "tools": [{
5468 "type": "function",
5469 "function": {
5470 "name": "weather",
5471 "description": "Get weather",
5472 "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
5473 }
5474 }],
5475 "tool_choice": "auto"
5476 }),
5477 )
5478 .await;
5479 assert_eq!(response.status(), AxumStatusCode::OK);
5480
5481 let request = engine.last_request();
5482 assert!(request.prompt.contains("<tools>weather</tools>"));
5483 assert!(
5484 request.prompt.contains("<tool_call>weather:"),
5485 "{}",
5486 request.prompt
5487 );
5488 assert!(request.prompt.contains("\"city\""), "{}", request.prompt);
5489 assert!(request.prompt.contains("Paris"), "{}", request.prompt);
5490 assert!(request
5491 .prompt
5492 .contains("<tool_response id=\"call_1\">sunny</tool_response>"));
5493 assert!(
5494 !request.prompt.contains("<|assistant|>"),
5495 "model-template tool prompt should not use generic fallback: {}",
5496 request.prompt
5497 );
5498 assert!(
5499 !request.prompt.contains("When a tool is needed"),
5500 "model-template tool prompt should not inject fallback tool instructions: {}",
5501 request.prompt
5502 );
5503 }
5504
5505 #[tokio::test]
5506 async fn chat_accepts_stop_string_and_max_completion_tokens() {
5507 let (router, engine) = router_with_capturing_llm();
5508 let response = post_json(
5509 router,
5510 "/v1/chat/completions",
5511 json!({
5512 "model": "stub-model",
5513 "messages": [{"role": "user", "content": "hello"}],
5514 "max_tokens": 99,
5515 "max_completion_tokens": 3,
5516 "stop": "<END>"
5517 }),
5518 )
5519 .await;
5520 assert_eq!(response.status(), AxumStatusCode::OK);
5521
5522 let request = engine.last_request();
5523 assert_eq!(request.sampling_params.max_tokens, 3);
5524 assert_eq!(
5525 request.sampling_params.temperature,
5526 DEFAULT_SAMPLING_TEMPERATURE
5527 );
5528 assert_eq!(request.sampling_params.stop_sequences, vec!["<END>"]);
5529 }
5530
5531 #[tokio::test]
5532 async fn chat_request_forbids_initial_think_close_token() {
5533 let engine = Arc::new(CapturingLlm::new());
5534 let router = AxumServer::from_llm(engine.clone()).build_router();
5535 let response = post_json(
5536 router,
5537 "/v1/chat/completions",
5538 json!({
5539 "model": "stub-model",
5540 "messages": [{"role": "user", "content": "hello"}]
5541 }),
5542 )
5543 .await;
5544 assert_eq!(response.status(), AxumStatusCode::OK);
5545
5546 let request = engine.last_request();
5547 assert_eq!(
5548 request
5549 .metadata
5550 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
5551 Some(&serde_json::json!([THINK_END_TAG]))
5552 );
5553 }
5554
5555 #[tokio::test]
5556 async fn chat_template_enable_thinking_default_is_template_controlled() {
5557 let template = ModelChatTemplate::new(
5558 "{% 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' }}{% endif %}{% endif %}",
5559 "test-template",
5560 );
5561 let (router, engine) = router_with_capturing_llm_and_template(template);
5562 let response = post_json(
5563 router,
5564 "/v1/chat/completions",
5565 json!({
5566 "model": "served-alias",
5567 "messages": [{"role": "user", "content": "hello"}]
5568 }),
5569 )
5570 .await;
5571 assert_eq!(response.status(), AxumStatusCode::OK);
5572
5573 let request = engine.last_request();
5574 assert!(request
5575 .prompt
5576 .ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
5577 assert_eq!(
5578 request
5579 .metadata
5580 .get(INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY),
5581 Some(&serde_json::json!([THINK_END_TAG, THINK_START_TAG]))
5582 );
5583 }
5584
5585 #[tokio::test]
5586 async fn chat_template_enable_thinking_true_overrides_default() {
5587 let template = ModelChatTemplate::new(
5588 "{% if add_generation_prompt %}<assistant>{% if enable_thinking is defined and enable_thinking is false %}<think>\n\n</think>\n\n{% endif %}{% endif %}",
5589 "test-template",
5590 );
5591 let (router, engine) = router_with_capturing_llm_and_template(template);
5592 let response = post_json(
5593 router,
5594 "/v1/chat/completions",
5595 json!({
5596 "model": "served-alias",
5597 "messages": [{"role": "user", "content": "hello"}],
5598 "chat_template_kwargs": {"enable_thinking": true}
5599 }),
5600 )
5601 .await;
5602 assert_eq!(response.status(), AxumStatusCode::OK);
5603
5604 let request = engine.last_request();
5605 assert_eq!(request.prompt, "<assistant>");
5606 }
5607
5608 #[tokio::test]
5609 async fn chat_template_enable_thinking_rejects_non_bool() {
5610 let template = ModelChatTemplate::new(
5611 "{% if add_generation_prompt %}<assistant>{% endif %}",
5612 "test-template",
5613 );
5614 let (router, _) = router_with_capturing_llm_and_template(template);
5615 let response = post_json(
5616 router,
5617 "/v1/chat/completions",
5618 json!({
5619 "model": "served-alias",
5620 "messages": [{"role": "user", "content": "hello"}],
5621 "chat_template_kwargs": {"enable_thinking": "false"}
5622 }),
5623 )
5624 .await;
5625 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5626 let body = response_json(response).await;
5627 assert_eq!(body["error"]["type"], "invalid_request_error");
5628 assert!(body["error"]["message"]
5629 .as_str()
5630 .unwrap_or_default()
5631 .contains("chat_template_kwargs.enable_thinking must be a boolean"));
5632 }
5633
5634 #[tokio::test]
5635 async fn stop_string_strips_chat_and_completion_suffixes() {
5636 let chat = post_json(
5637 router_with_stub("hello<END>"),
5638 "/v1/chat/completions",
5639 json!({
5640 "model": "stub-model",
5641 "messages": [{"role": "user", "content": "hello"}],
5642 "stop": "<END>"
5643 }),
5644 )
5645 .await;
5646 assert_eq!(chat.status(), AxumStatusCode::OK);
5647 let chat_body = response_json(chat).await;
5648 assert_eq!(chat_body["choices"][0]["message"]["content"], "hello");
5649
5650 let completion = post_json(
5651 router_with_stub("done<END>"),
5652 "/v1/completions",
5653 json!({
5654 "model": "stub-model",
5655 "prompt": "complete",
5656 "stop": "<END>"
5657 }),
5658 )
5659 .await;
5660 assert_eq!(completion.status(), AxumStatusCode::OK);
5661 let completion_body = response_json(completion).await;
5662 assert_eq!(completion_body["choices"][0]["text"], "done");
5663 }
5664
5665 #[tokio::test]
5666 async fn chat_response_splits_reasoning_from_content() {
5667 let response = post_json(
5668 router_with_stub("<think>\nreasoning\n</think>\n\nfinal answer"),
5669 "/v1/chat/completions",
5670 json!({
5671 "model": "stub-model",
5672 "messages": [{"role": "user", "content": "hello"}]
5673 }),
5674 )
5675 .await;
5676 assert_eq!(response.status(), AxumStatusCode::OK);
5677
5678 let body = response_json(response).await;
5679 let message = &body["choices"][0]["message"];
5680 assert_eq!(message["content"], "final answer");
5681 assert_eq!(message["reasoning"], "\nreasoning\n");
5682 assert!(message.get("reasoning_content").is_none());
5683 }
5684
5685 #[tokio::test]
5686 async fn streaming_chat_reasoning_prefix_chunks_do_not_panic_or_leak_content() {
5687 let response = post_json(
5688 router_with_stub_stream_chunks(&["<", "think", ">\nreason", "\n</think>\n\nfinal"]),
5689 "/v1/chat/completions",
5690 json!({
5691 "model": "stub-model",
5692 "messages": [{"role": "user", "content": "think then answer"}],
5693 "stream": true
5694 }),
5695 )
5696 .await;
5697 assert_eq!(response.status(), AxumStatusCode::OK);
5698 let body = response_text(response).await;
5699 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
5700 assert!(
5701 body.contains(r#""reasoning":"\nreason"#),
5702 "stream should emit reasoning delta after full think prefix: {body}"
5703 );
5704 assert!(
5705 body.contains(r#""content":"final""#),
5706 "stream should emit visible content after think close: {body}"
5707 );
5708 assert!(
5709 !body.contains(r#""content":"<"#),
5710 "partial think prefix must not leak as content: {body}"
5711 );
5712 }
5713
5714 #[tokio::test]
5715 async fn route_rejects_unsupported_tool_and_function_selection() {
5716 for (extra, param) in [
5717 (
5718 json!({
5719 "tools": [{
5720 "type": "function",
5721 "function": {"name": "weather", "parameters": {"type": "object"}}
5722 }],
5723 "tool_choice": {
5724 "type": "function",
5725 "function": {"name": "calendar"}
5726 }
5727 }),
5728 "tool_choice",
5729 ),
5730 (
5731 json!({
5732 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
5733 "function_call": {"name": "calendar"}
5734 }),
5735 "function_call",
5736 ),
5737 ] {
5738 let mut body = json!({
5739 "model": "stub-model",
5740 "messages": [{"role": "user", "content": "hello"}]
5741 });
5742 body.as_object_mut()
5743 .expect("object")
5744 .extend(extra.as_object().expect("extra object").clone());
5745 let response =
5746 post_json(router_with_stub("unused"), "/v1/chat/completions", body).await;
5747 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5748 let body = response_json(response).await;
5749 assert_eq!(body["error"]["type"], "invalid_request_error");
5750 assert_eq!(body["error"]["param"], param);
5751 }
5752 }
5753
5754 #[tokio::test]
5755 async fn route_rejects_non_function_tools_with_openai_error_param() {
5756 let response = post_json(
5757 router_with_stub("unused"),
5758 "/v1/chat/completions",
5759 json!({
5760 "model": "stub-model",
5761 "messages": [{"role": "user", "content": "hello"}],
5762 "tools": [{
5763 "type": "retrieval",
5764 "function": {"name": "search", "parameters": {"type": "object"}}
5765 }]
5766 }),
5767 )
5768 .await;
5769 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5770 let body = response_json(response).await;
5771 assert_eq!(body["error"]["type"], "invalid_request_error");
5772 assert_eq!(body["error"]["param"], "tools");
5773 }
5774
5775 #[tokio::test]
5776 async fn route_rejects_tool_choice_required_without_tools() {
5777 let response = post_json(
5778 router_with_stub("unused"),
5779 "/v1/chat/completions",
5780 json!({
5781 "model": "stub-model",
5782 "messages": [{"role": "user", "content": "hello"}],
5783 "tool_choice": "required"
5784 }),
5785 )
5786 .await;
5787 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5788 let body = response_json(response).await;
5789 assert_eq!(body["error"]["type"], "invalid_request_error");
5790 assert_eq!(body["error"]["param"], "tool_choice");
5791 }
5792
5793 #[tokio::test]
5794 async fn route_rejects_unknown_response_format_type_with_openai_error_param() {
5795 let response = post_json(
5796 router_with_stub("unused"),
5797 "/v1/chat/completions",
5798 json!({
5799 "model": "stub-model",
5800 "messages": [{"role": "user", "content": "hello"}],
5801 "response_format": {"type": "xml"}
5802 }),
5803 )
5804 .await;
5805 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5806 let body = response_json(response).await;
5807 assert_eq!(body["error"]["type"], "invalid_request_error");
5808 assert_eq!(body["error"]["param"], "response_format.type");
5809 }
5810
5811 #[tokio::test]
5812 async fn route_chat_engine_unavailable_maps_to_503() {
5813 let response = post_json(
5814 router_without_llm(),
5815 "/v1/chat/completions",
5816 json!({
5817 "model": "stub-model",
5818 "messages": [{"role": "user", "content": "hello"}]
5819 }),
5820 )
5821 .await;
5822 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
5823 let body = response_json(response).await;
5824 assert_eq!(body["error"]["type"], "service_unavailable_error");
5825 assert_eq!(body["error"]["param"], Value::Null);
5826 }
5827
5828 #[tokio::test]
5829 async fn route_chat_generation_failure_maps_to_500() {
5830 let response = post_json(
5831 router_with_failing_llm(),
5832 "/v1/chat/completions",
5833 json!({
5834 "model": "failing-model",
5835 "messages": [{"role": "user", "content": "hello"}]
5836 }),
5837 )
5838 .await;
5839 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
5840 let body = response_json(response).await;
5841 assert_eq!(body["error"]["type"], "internal_server_error");
5842 assert!(body["error"]["message"]
5843 .as_str()
5844 .unwrap()
5845 .contains("stub generation failed"));
5846 }
5847
5848 #[tokio::test]
5849 async fn route_chat_stream_generation_failure_emits_openai_error_event() {
5850 let response = post_json(
5851 router_with_failing_llm(),
5852 "/v1/chat/completions",
5853 json!({
5854 "model": "failing-model",
5855 "messages": [{"role": "user", "content": "hello"}],
5856 "stream": true
5857 }),
5858 )
5859 .await;
5860 assert_eq!(response.status(), AxumStatusCode::OK);
5861 let body = response_text(response).await;
5862 assert_openai_stream_error(&body, "stub stream failed");
5863 }
5864
5865 #[tokio::test]
5866 async fn route_chat_stream_chunk_failure_emits_openai_error_event() {
5867 let response = post_json(
5868 router_with_stream_chunk_failing_llm(),
5869 "/v1/chat/completions",
5870 json!({
5871 "model": "failing-model",
5872 "messages": [{"role": "user", "content": "hello"}],
5873 "stream": true
5874 }),
5875 )
5876 .await;
5877 assert_eq!(response.status(), AxumStatusCode::OK);
5878 let body = response_text(response).await;
5879 assert_openai_stream_error(&body, "stub stream chunk failed");
5880 }
5881
5882 #[tokio::test]
5883 async fn route_completions_engine_unavailable_maps_to_503() {
5884 let response = post_json(
5885 router_without_llm(),
5886 "/v1/completions",
5887 json!({
5888 "model": "stub-model",
5889 "prompt": "complete me"
5890 }),
5891 )
5892 .await;
5893 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
5894 let body = response_json(response).await;
5895 assert_eq!(body["error"]["type"], "service_unavailable_error");
5896 assert_eq!(body["error"]["param"], Value::Null);
5897 }
5898
5899 #[tokio::test]
5900 async fn route_embeddings_engine_unavailable_maps_to_503() {
5901 let response = post_json(
5902 router_without_llm(),
5903 "/v1/embeddings",
5904 json!({
5905 "model": "embed-model",
5906 "input": "hello"
5907 }),
5908 )
5909 .await;
5910 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
5911 let body = response_json(response).await;
5912 assert_eq!(body["error"]["type"], "service_unavailable_error");
5913 assert_eq!(body["error"]["param"], Value::Null);
5914 }
5915
5916 #[tokio::test]
5917 async fn route_embeddings_contract_uses_stub_engine() {
5918 let response = post_json(
5919 router_with_stub_embed(),
5920 "/v1/embeddings",
5921 json!({
5922 "model": "stub-embed",
5923 "input": ["hi", "world"],
5924 "encoding_format": "float"
5925 }),
5926 )
5927 .await;
5928 assert_eq!(response.status(), AxumStatusCode::OK);
5929 let body = response_json(response).await;
5930 assert_eq!(body["object"], "list");
5931 assert_eq!(body["model"], "stub-embed");
5932 assert_eq!(body["usage"]["prompt_tokens"], 7);
5933 assert_eq!(body["usage"]["total_tokens"], 7);
5934
5935 let data = body["data"].as_array().expect("embedding data");
5936 assert_eq!(data.len(), 2, "body: {body}");
5937 assert_eq!(data[0]["object"], "embedding");
5938 assert_eq!(data[0]["index"], 0);
5939 assert_eq!(data[0]["embedding"].as_array().unwrap().len(), 3);
5940 assert_eq!(data[0]["embedding"][0].as_f64().unwrap(), 2.0);
5941 assert_eq!(data[1]["index"], 1);
5942 assert_eq!(data[1]["embedding"][0].as_f64().unwrap(), 5.0);
5943 }
5944
5945 #[tokio::test]
5946 async fn route_embeddings_rejects_unsupported_encoding_format() {
5947 let response = post_json(
5948 router_with_stub_embed(),
5949 "/v1/embeddings",
5950 json!({
5951 "model": "stub-embed",
5952 "input": "hi",
5953 "encoding_format": "base64"
5954 }),
5955 )
5956 .await;
5957 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5958 let body = response_json(response).await;
5959 assert_eq!(body["error"]["type"], "invalid_request_error");
5960 assert_eq!(body["error"]["param"], "encoding_format");
5961 }
5962
5963 #[tokio::test]
5964 async fn route_embeddings_rejects_empty_input_with_field_param() {
5965 let response = post_json(
5966 router_with_stub_embed(),
5967 "/v1/embeddings",
5968 json!({
5969 "model": "stub-embed",
5970 "input": []
5971 }),
5972 )
5973 .await;
5974 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5975 let body = response_json(response).await;
5976 assert_eq!(body["error"]["type"], "invalid_request_error");
5977 assert_eq!(body["error"]["param"], "input");
5978 }
5979
5980 #[tokio::test]
5981 async fn route_embeddings_rejects_empty_item_with_field_param() {
5982 let response = post_json(
5983 router_with_stub_embed(),
5984 "/v1/embeddings",
5985 json!({
5986 "model": "stub-embed",
5987 "input": [{}]
5988 }),
5989 )
5990 .await;
5991 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
5992 let body = response_json(response).await;
5993 assert_eq!(body["error"]["type"], "invalid_request_error");
5994 assert_eq!(body["error"]["param"], "input");
5995 }
5996
5997 #[tokio::test]
5998 async fn route_embeddings_invalid_json_maps_to_openai_error() {
5999 let response = post_raw_json(router_with_stub_embed(), "/v1/embeddings", "{").await;
6000 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6001 let body = response_json(response).await;
6002 assert_eq!(body["error"]["type"], "invalid_request_error");
6003 assert_eq!(body["error"]["param"], Value::Null);
6004 assert!(body["error"]["message"]
6005 .as_str()
6006 .unwrap()
6007 .contains("invalid embeddings request"));
6008 }
6009
6010 #[tokio::test]
6011 async fn route_transcriptions_engine_unavailable_maps_to_503() {
6012 let boundary = "ferrum-test-boundary";
6013 let body = concat!(
6014 "--ferrum-test-boundary\r\n",
6015 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
6016 "Content-Type: audio/wav\r\n",
6017 "\r\n",
6018 "RIFFtest\r\n",
6019 "--ferrum-test-boundary--\r\n"
6020 );
6021 let response = post_multipart(
6022 router_without_llm(),
6023 "/v1/audio/transcriptions",
6024 boundary,
6025 body,
6026 )
6027 .await;
6028 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
6029 let body = response_json(response).await;
6030 assert_eq!(body["error"]["type"], "service_unavailable_error");
6031 assert_eq!(body["error"]["param"], Value::Null);
6032 }
6033
6034 #[tokio::test]
6035 async fn route_transcriptions_contract_uses_stub_engine() {
6036 let boundary = "ferrum-test-boundary";
6037 let body = concat!(
6038 "--ferrum-test-boundary\r\n",
6039 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
6040 "Content-Type: audio/wav\r\n",
6041 "\r\n",
6042 "RIFFtest\r\n",
6043 "--ferrum-test-boundary\r\n",
6044 "Content-Disposition: form-data; name=\"language\"\r\n",
6045 "\r\n",
6046 "en\r\n",
6047 "--ferrum-test-boundary\r\n",
6048 "Content-Disposition: form-data; name=\"response_format\"\r\n",
6049 "\r\n",
6050 "json\r\n",
6051 "--ferrum-test-boundary--\r\n"
6052 );
6053 let response = post_multipart(
6054 router_with_stub_transcribe(),
6055 "/v1/audio/transcriptions",
6056 boundary,
6057 body,
6058 )
6059 .await;
6060 assert_eq!(response.status(), AxumStatusCode::OK);
6061 let body = response_json(response).await;
6062 assert_eq!(body["text"], "bytes:8:en");
6063 }
6064
6065 #[tokio::test]
6066 async fn route_transcriptions_rejects_unsupported_response_format() {
6067 let boundary = "ferrum-test-boundary";
6068 let body = concat!(
6069 "--ferrum-test-boundary\r\n",
6070 "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n",
6071 "Content-Type: audio/wav\r\n",
6072 "\r\n",
6073 "RIFFtest\r\n",
6074 "--ferrum-test-boundary\r\n",
6075 "Content-Disposition: form-data; name=\"response_format\"\r\n",
6076 "\r\n",
6077 "text\r\n",
6078 "--ferrum-test-boundary--\r\n"
6079 );
6080 let response = post_multipart(
6081 router_with_stub_transcribe(),
6082 "/v1/audio/transcriptions",
6083 boundary,
6084 body,
6085 )
6086 .await;
6087 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6088 let body = response_json(response).await;
6089 assert_eq!(body["error"]["type"], "invalid_request_error");
6090 assert_eq!(body["error"]["param"], "response_format");
6091 }
6092
6093 #[tokio::test]
6094 async fn route_transcriptions_rejects_missing_file_with_field_param() {
6095 let boundary = "ferrum-test-boundary";
6096 let body = concat!(
6097 "--ferrum-test-boundary\r\n",
6098 "Content-Disposition: form-data; name=\"language\"\r\n",
6099 "\r\n",
6100 "en\r\n",
6101 "--ferrum-test-boundary--\r\n"
6102 );
6103 let response = post_multipart(
6104 router_with_stub_transcribe(),
6105 "/v1/audio/transcriptions",
6106 boundary,
6107 body,
6108 )
6109 .await;
6110 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6111 let body = response_json(response).await;
6112 assert_eq!(body["error"]["type"], "invalid_request_error");
6113 assert_eq!(body["error"]["param"], "file");
6114 }
6115
6116 #[tokio::test]
6117 async fn route_transcriptions_invalid_multipart_maps_to_openai_error() {
6118 let response = post_json(
6119 router_with_stub_transcribe(),
6120 "/v1/audio/transcriptions",
6121 json!({}),
6122 )
6123 .await;
6124 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6125 let body = response_json(response).await;
6126 assert_eq!(body["error"]["type"], "invalid_request_error");
6127 assert_eq!(body["error"]["param"], Value::Null);
6128 assert!(body["error"]["message"]
6129 .as_str()
6130 .unwrap()
6131 .contains("invalid transcriptions request"));
6132 }
6133
6134 #[tokio::test]
6135 async fn route_speech_engine_unavailable_maps_to_503() {
6136 let response = post_json(
6137 router_without_llm(),
6138 "/v1/audio/speech",
6139 json!({
6140 "model": "tts-model",
6141 "input": "hello",
6142 "voice": "default"
6143 }),
6144 )
6145 .await;
6146 assert_eq!(response.status(), AxumStatusCode::SERVICE_UNAVAILABLE);
6147 let body = response_json(response).await;
6148 assert_eq!(body["error"]["type"], "service_unavailable_error");
6149 assert_eq!(body["error"]["param"], Value::Null);
6150 }
6151
6152 #[tokio::test]
6153 async fn route_speech_contract_uses_stub_engine() {
6154 let response = post_json(
6155 router_with_stub_tts(),
6156 "/v1/audio/speech",
6157 json!({
6158 "model": "stub-tts",
6159 "input": "hello",
6160 "voice": "default",
6161 "response_format": "wav",
6162 "language": "english"
6163 }),
6164 )
6165 .await;
6166 assert_eq!(response.status(), AxumStatusCode::OK);
6167 assert_eq!(
6168 response.headers().get(header::CONTENT_TYPE).unwrap(),
6169 "audio/wav"
6170 );
6171 let body = response_bytes(response).await;
6172 assert!(body.len() > 44, "WAV should include header and PCM data");
6173 assert_eq!(&body[0..4], b"RIFF");
6174 assert_eq!(&body[8..12], b"WAVE");
6175 }
6176
6177 #[tokio::test]
6178 async fn route_speech_streaming_contract_uses_stub_engine() {
6179 let response = post_json(
6180 router_with_stub_tts(),
6181 "/v1/audio/speech",
6182 json!({
6183 "model": "stub-tts",
6184 "input": "hello",
6185 "voice": "default",
6186 "response_format": "wav",
6187 "stream": true
6188 }),
6189 )
6190 .await;
6191 assert_eq!(response.status(), AxumStatusCode::OK);
6192 assert_eq!(
6193 response.headers().get(header::CONTENT_TYPE).unwrap(),
6194 "audio/wav"
6195 );
6196 assert_eq!(
6197 response.headers().get(header::TRANSFER_ENCODING).unwrap(),
6198 "chunked"
6199 );
6200 let body = response_bytes(response).await;
6201 assert!(body.len() > 44, "streaming WAV should include audio bytes");
6202 assert_eq!(&body[0..4], b"RIFF");
6203 assert_eq!(&body[8..12], b"WAVE");
6204 }
6205
6206 #[tokio::test]
6207 async fn route_speech_pcm_response_format_returns_raw_pcm() {
6208 let response = post_json(
6209 router_with_stub_tts(),
6210 "/v1/audio/speech",
6211 json!({
6212 "model": "stub-tts",
6213 "input": "hello",
6214 "voice": "default",
6215 "response_format": "pcm"
6216 }),
6217 )
6218 .await;
6219 assert_eq!(response.status(), AxumStatusCode::OK);
6220 assert_eq!(
6221 response.headers().get(header::CONTENT_TYPE).unwrap(),
6222 "audio/pcm"
6223 );
6224 let body = response_bytes(response).await;
6225 assert_eq!(body.len(), 6, "three f32 samples should encode as s16le");
6226 assert_eq!(&body[0..2], &[0, 0]);
6227 assert_ne!(&body[0..4], b"RIFF");
6228 }
6229
6230 #[tokio::test]
6231 async fn route_speech_rejects_unsupported_response_format() {
6232 let response = post_json(
6233 router_with_stub_tts(),
6234 "/v1/audio/speech",
6235 json!({
6236 "model": "stub-tts",
6237 "input": "hello",
6238 "voice": "default",
6239 "response_format": "mp3"
6240 }),
6241 )
6242 .await;
6243 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6244 let body = response_json(response).await;
6245 assert_eq!(body["error"]["type"], "invalid_request_error");
6246 assert_eq!(body["error"]["param"], "response_format");
6247 }
6248
6249 #[tokio::test]
6250 async fn route_speech_invalid_json_maps_to_openai_error() {
6251 let response = post_raw_json(router_with_stub_tts(), "/v1/audio/speech", "{").await;
6252 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6253 let body = response_json(response).await;
6254 assert_eq!(body["error"]["type"], "invalid_request_error");
6255 assert_eq!(body["error"]["param"], Value::Null);
6256 assert!(body["error"]["message"]
6257 .as_str()
6258 .unwrap()
6259 .contains("invalid speech request"));
6260 }
6261
6262 #[tokio::test]
6263 async fn route_completions_generation_failure_maps_to_500() {
6264 let response = post_json(
6265 router_with_failing_llm(),
6266 "/v1/completions",
6267 json!({
6268 "model": "failing-model",
6269 "prompt": "complete me"
6270 }),
6271 )
6272 .await;
6273 assert_eq!(response.status(), AxumStatusCode::INTERNAL_SERVER_ERROR);
6274 let body = response_json(response).await;
6275 assert_eq!(body["error"]["type"], "internal_server_error");
6276 assert!(body["error"]["message"]
6277 .as_str()
6278 .unwrap()
6279 .contains("stub generation failed"));
6280 }
6281
6282 #[tokio::test]
6283 async fn route_completions_stream_generation_failure_emits_openai_error_event() {
6284 let response = post_json(
6285 router_with_failing_llm(),
6286 "/v1/completions",
6287 json!({
6288 "model": "failing-model",
6289 "prompt": "complete me",
6290 "stream": true
6291 }),
6292 )
6293 .await;
6294 assert_eq!(response.status(), AxumStatusCode::OK);
6295 let body = response_text(response).await;
6296 assert_openai_stream_error(&body, "stub stream failed");
6297 }
6298
6299 #[tokio::test]
6300 async fn route_completions_stream_chunk_failure_emits_openai_error_event() {
6301 let response = post_json(
6302 router_with_stream_chunk_failing_llm(),
6303 "/v1/completions",
6304 json!({
6305 "model": "failing-model",
6306 "prompt": "complete me",
6307 "stream": true
6308 }),
6309 )
6310 .await;
6311 assert_eq!(response.status(), AxumStatusCode::OK);
6312 let body = response_text(response).await;
6313 assert_openai_stream_error(&body, "stub stream chunk failed");
6314 }
6315
6316 #[tokio::test]
6317 async fn route_completions_contract_uses_stub_engine() {
6318 let response = post_json(
6319 router_with_stub("done"),
6320 "/v1/completions",
6321 json!({
6322 "model": "stub-model",
6323 "prompt": "complete me",
6324 "max_tokens": 8,
6325 "temperature": 0.0
6326 }),
6327 )
6328 .await;
6329 assert_eq!(response.status(), AxumStatusCode::OK);
6330 let body = response_json(response).await;
6331 assert_eq!(body["object"], "text_completion");
6332 assert_eq!(body["choices"][0]["text"], "done");
6333 assert_eq!(body["usage"]["prompt_tokens"], 7);
6334 assert_eq!(body["usage"]["completion_tokens"], 2);
6335 }
6336
6337 #[tokio::test]
6338 async fn route_completions_streaming_contract_uses_stub_engine() {
6339 let response = post_json(
6340 router_with_stub("done"),
6341 "/v1/completions",
6342 json!({
6343 "model": "stub-model",
6344 "prompt": "complete me",
6345 "max_tokens": 8,
6346 "temperature": 0.0,
6347 "stream": true
6348 }),
6349 )
6350 .await;
6351 assert_eq!(response.status(), AxumStatusCode::OK);
6352 let body = response_text(response).await;
6353 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6354 assert!(
6355 body.contains("\"object\":\"text_completion\""),
6356 "missing completion chunk: {body}"
6357 );
6358 assert!(body.contains("\"text\":\"done\""), "missing text: {body}");
6359 assert!(
6360 body.contains("\"choices\":[],\"usage\""),
6361 "missing separate usage chunk: {body}"
6362 );
6363 assert!(
6364 body.contains("\"prompt_tokens\":5"),
6365 "stream usage should come from engine token usage: {body}"
6366 );
6367 assert!(
6368 body.contains("\"completion_tokens\":1"),
6369 "stream completion usage should come from engine token usage: {body}"
6370 );
6371 }
6372
6373 #[tokio::test]
6374 async fn route_completions_stream_waits_for_separate_final_usage_at_max_tokens() {
6375 let response = post_json(
6376 router_with_stub_separate_final_stream_chunk(&["do", "ne"]),
6377 "/v1/completions",
6378 json!({
6379 "model": "stub-model",
6380 "prompt": "complete me",
6381 "max_tokens": 2,
6382 "temperature": 0.0,
6383 "stream": true
6384 }),
6385 )
6386 .await;
6387 assert_eq!(response.status(), AxumStatusCode::OK);
6388 let body = response_text(response).await;
6389 assert_eq!(body.matches("data: [DONE]").count(), 1, "body: {body}");
6390 assert!(
6391 body.contains("\"text\":\"do\""),
6392 "missing first chunk: {body}"
6393 );
6394 assert!(
6395 body.contains("\"text\":\"ne\""),
6396 "missing second chunk: {body}"
6397 );
6398 assert!(
6399 body.contains("\"choices\":[],\"usage\""),
6400 "missing separate usage chunk from final engine chunk: {body}"
6401 );
6402 assert!(
6403 body.contains("\"prompt_tokens\":5"),
6404 "stream usage should come from engine final usage: {body}"
6405 );
6406 }
6407
6408 #[tokio::test]
6409 async fn route_completions_invalid_json_maps_to_openai_error() {
6410 let response = post_raw_json(router_with_stub("unused"), "/v1/completions", "{").await;
6411 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6412 let body = response_json(response).await;
6413 assert_eq!(body["error"]["type"], "invalid_request_error");
6414 assert_eq!(body["error"]["param"], Value::Null);
6415 assert!(body["error"]["message"]
6416 .as_str()
6417 .unwrap()
6418 .contains("invalid completions request"));
6419 }
6420
6421 #[tokio::test]
6422 async fn route_completions_rejects_unsupported_fields_explicitly() {
6423 for (extra, param) in [
6424 (json!({"n": 2}), "n"),
6425 (json!({"logprobs": 3}), "logprobs"),
6426 (json!({"logit_bias": {"42": 1.0}}), "logit_bias"),
6427 ] {
6428 let mut body = json!({
6429 "model": "stub-model",
6430 "prompt": "complete me"
6431 });
6432 body.as_object_mut()
6433 .expect("object")
6434 .extend(extra.as_object().expect("extra object").clone());
6435 let response = post_json(router_with_stub("unused"), "/v1/completions", body).await;
6436 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
6437 let body = response_json(response).await;
6438 assert_eq!(body["error"]["type"], "invalid_request_error");
6439 assert_eq!(body["error"]["param"], param);
6440 }
6441 }
6442
6443 #[tokio::test]
6444 async fn streaming_completions_do_not_synthesize_whitespace_usage() {
6445 let response = post_json(
6446 router_with_stub_without_stream_usage("done"),
6447 "/v1/completions",
6448 json!({
6449 "model": "stub-model",
6450 "prompt": "one two three four",
6451 "stream": true
6452 }),
6453 )
6454 .await;
6455 assert_eq!(response.status(), AxumStatusCode::OK);
6456 let body = response_text(response).await;
6457 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6458 assert!(
6459 !body.contains("\"usage\":{\"prompt_tokens\""),
6460 "server must not synthesize whitespace-count completion usage: {body}"
6461 );
6462 }
6463
6464 #[tokio::test]
6465 async fn chat_rejects_n_not_one_with_openai_error_param() {
6466 let request = chat_request(json!({"n": 2}));
6467 let err = chat_completions_handler(
6468 State(state_with_stub("unused")),
6469 HeaderMap::new(),
6470 Ok(Json(request)),
6471 )
6472 .await
6473 .expect_err("n=2 should reject");
6474 let (status, body) = error_json(err).await;
6475 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
6476 assert_eq!(body["error"]["type"], "invalid_request_error");
6477 assert_eq!(body["error"]["param"], "n");
6478 }
6479
6480 #[tokio::test]
6481 async fn chat_rejects_logit_bias_and_logprobs_explicitly() {
6482 for (extra, param) in [
6483 (json!({"logit_bias": {"1": 100.0}}), "logit_bias"),
6484 (json!({"logprobs": true}), "logprobs"),
6485 (json!({"top_logprobs": 2}), "top_logprobs"),
6486 ] {
6487 let request = chat_request(extra);
6488 let err = chat_completions_handler(
6489 State(state_with_stub("unused")),
6490 HeaderMap::new(),
6491 Ok(Json(request)),
6492 )
6493 .await
6494 .expect_err("unsupported field should reject");
6495 let (status, body) = error_json(err).await;
6496 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
6497 assert_eq!(body["error"]["param"], param);
6498 assert_eq!(body["error"]["type"], "invalid_request_error");
6499 }
6500 }
6501
6502 #[tokio::test]
6503 async fn chat_stream_options_include_usage_controls_stream_usage() {
6504 let request = chat_request(json!({
6505 "stream": true,
6506 "stream_options": {"include_usage": true}
6507 }));
6508 let response = chat_completions_handler(
6509 State(state_with_stub("ok")),
6510 HeaderMap::new(),
6511 Ok(Json(request)),
6512 )
6513 .await
6514 .expect("stream response");
6515 assert_eq!(response.status(), AxumStatusCode::OK);
6516 let body = response_text(response).await;
6517 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6518 assert!(
6519 body.contains("\"usage\"") && body.contains("\"completion_tokens\":1"),
6520 "include_usage=true should emit stream usage: {body}"
6521 );
6522 assert!(
6523 body.contains("\"choices\":[],\"usage\""),
6524 "include_usage=true should use a separate usage chunk: {body}"
6525 );
6526 assert!(
6527 body.contains("\"prompt_tokens\":5"),
6528 "stream usage should come from engine token usage: {body}"
6529 );
6530
6531 let request = chat_request(json!({"stream": true}));
6532 let response = chat_completions_handler(
6533 State(state_with_stub("ok")),
6534 HeaderMap::new(),
6535 Ok(Json(request)),
6536 )
6537 .await
6538 .expect("stream response");
6539 let body = response_text(response).await;
6540 assert!(
6541 !body.contains("\"usage\":{\"prompt_tokens\""),
6542 "stream usage should be omitted unless requested: {body}"
6543 );
6544 }
6545
6546 #[tokio::test]
6547 async fn streaming_chat_does_not_synthesize_whitespace_usage() {
6548 let response = post_json(
6549 router_with_stub_without_stream_usage("ok"),
6550 "/v1/chat/completions",
6551 json!({
6552 "model": "stub-model",
6553 "messages": [{"role": "user", "content": "one two three four"}],
6554 "stream": true,
6555 "stream_options": {"include_usage": true}
6556 }),
6557 )
6558 .await;
6559 assert_eq!(response.status(), AxumStatusCode::OK);
6560 let body = response_text(response).await;
6561 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
6562 assert!(
6563 !body.contains("\"usage\":{\"prompt_tokens\""),
6564 "server must not synthesize whitespace-count usage when engine stream omits usage: {body}"
6565 );
6566 }
6567
6568 #[test]
6569 fn tool_requests_and_tool_messages_parse_into_structured_api_request() {
6570 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6571 "model": "qwen3",
6572 "messages": [
6573 {"role": "user", "content": "Use the weather tool."},
6574 {
6575 "role": "assistant",
6576 "content": null,
6577 "tool_calls": [{
6578 "id": "call_1",
6579 "type": "function",
6580 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
6581 }]
6582 },
6583 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}
6584 ],
6585 "tools": [{
6586 "type": "function",
6587 "function": {
6588 "name": "weather",
6589 "description": "Get weather",
6590 "parameters": {
6591 "type": "object",
6592 "properties": {"city": {"type": "string"}},
6593 "required": ["city"]
6594 }
6595 }
6596 }],
6597 "tool_choice": "auto"
6598 }))
6599 .expect("tool request parses");
6600
6601 validate_chat_request(&request).expect("tool request validates");
6602 let internal = convert_chat_request(&request).expect("convert");
6603 assert!(internal.prompt.contains("\"tools\":[{"));
6604 assert!(internal.prompt.contains("\"type\":\"function\""));
6605 assert!(internal.prompt.contains("\"name\":\"weather\""));
6606 assert!(internal.prompt.contains("<|im_start|>assistant\n{"));
6607 assert!(internal.prompt.contains("\"tool_calls\":[{"));
6608 assert!(internal.prompt.contains("\"id\":\"call_1\""));
6609 assert!(internal
6610 .prompt
6611 .contains("<|im_start|>tool\nsunny<|im_end|>"));
6612 assert_eq!(
6613 internal.metadata["openai_tools"][0]["function"]["name"],
6614 "weather"
6615 );
6616 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
6617 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6618 panic!("expected structured chat api_request");
6619 };
6620 assert_eq!(api.messages.len(), 3);
6621 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Tool);
6622 assert_eq!(api.messages[2].content, "sunny");
6623 assert_eq!(api.messages[2].tool_call_id.as_deref(), Some("call_1"));
6624 assert_eq!(api.tools[0].function.name, "weather");
6625 assert_eq!(
6626 api.tool_choice,
6627 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
6628 );
6629 assert_eq!(
6630 api.messages[1].tool_calls[0].function.arguments,
6631 "{\"city\":\"Paris\"}"
6632 );
6633 }
6634
6635 #[test]
6636 fn omitted_tool_choice_defaults_to_auto_when_tools_are_present() {
6637 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6638 "model": "served-alias",
6639 "messages": [{"role": "user", "content": "Use the weather tool."}],
6640 "tools": [{
6641 "type": "function",
6642 "function": {
6643 "name": "weather",
6644 "description": "Get weather",
6645 "parameters": {
6646 "type": "object",
6647 "properties": {"city": {"type": "string"}},
6648 "required": ["city"]
6649 }
6650 }
6651 }]
6652 }))
6653 .expect("tool request parses");
6654
6655 validate_chat_request(&request).expect("tool request validates");
6656 let internal = convert_chat_request(&request).expect("convert");
6657 assert!(internal.prompt.contains("\"tools\":[{"));
6658 assert!(internal.prompt.contains("\"tool_choice\":\"auto\""));
6659 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
6660 let initial_forbidden = internal.metadata[INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY]
6661 .as_array()
6662 .expect("initial forbidden token list");
6663 for token in [
6664 THINK_END_TAG,
6665 "<|im_end|>",
6666 "<|endoftext|>",
6667 "<|eot_id|>",
6668 "</s>",
6669 ] {
6670 assert!(
6671 initial_forbidden.iter().any(|value| value == token),
6672 "missing initial forbidden token {token}: {initial_forbidden:?}"
6673 );
6674 }
6675 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6676 panic!("expected structured chat api_request");
6677 };
6678 assert_eq!(
6679 api.tool_choice,
6680 Some(ferrum_types::ApiToolChoice::Mode("auto".into()))
6681 );
6682 }
6683
6684 #[test]
6685 fn omitted_single_matching_tool_uses_tool_schema_response_format() {
6686 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6687 "model": "served-alias",
6688 "messages": [{"role": "user", "content": "北京现在天气怎么样?用摄氏度。"}],
6689 "tools": [{
6690 "type": "function",
6691 "function": {
6692 "name": "get_weather",
6693 "description": "查询指定城市的当前天气",
6694 "parameters": {
6695 "type": "object",
6696 "properties": {
6697 "city": {"type": "string"},
6698 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
6699 },
6700 "required": ["city"]
6701 }
6702 }
6703 }]
6704 }))
6705 .expect("tool request parses");
6706
6707 validate_chat_request(&request).expect("tool request validates");
6708 let internal = convert_chat_request(&request).expect("convert");
6709 assert_eq!(internal.metadata["openai_tool_choice"], "auto");
6710 match internal.sampling_params.response_format {
6711 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
6712 assert!(schema.contains(r#""required":["city"]"#), "{schema}");
6713 }
6714 ref other => panic!("expected inferred tool json schema, got {other:?}"),
6715 }
6716 }
6717
6718 #[test]
6719 fn tool_schema_response_format_bounds_unconstrained_strings() {
6720 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6721 "model": "served-alias",
6722 "messages": [{"role": "user", "content": "Use the selected tool."}],
6723 "tools": [{
6724 "type": "function",
6725 "function": {
6726 "name": "get_weather",
6727 "parameters": {
6728 "type": "object",
6729 "properties": {
6730 "city": {"type": "string"},
6731 "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
6732 },
6733 "required": ["city"]
6734 }
6735 }
6736 }],
6737 "tool_choice": {
6738 "type": "function",
6739 "function": {"name": "get_weather"}
6740 }
6741 }))
6742 .expect("tool request parses");
6743
6744 validate_chat_request(&request).expect("tool request validates");
6745 let internal = convert_chat_request(&request).expect("convert");
6746 match internal.sampling_params.response_format {
6747 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
6748 let value: serde_json::Value =
6749 serde_json::from_str(schema).expect("schema should be JSON");
6750 assert_eq!(
6751 value["properties"]["city"]["maxLength"],
6752 DEFAULT_GUIDED_TOOL_ARGUMENT_STRING_MAX_LENGTH
6753 );
6754 assert_eq!(
6755 value["properties"]["unit"]["enum"],
6756 json!(["celsius", "fahrenheit"])
6757 );
6758 assert!(
6759 value["properties"]["unit"]["maxLength"].is_null(),
6760 "enum string should remain finite via enum instead of maxLength: {value}"
6761 );
6762 }
6763 ref other => panic!("expected forced tool json schema, got {other:?}"),
6764 }
6765 }
6766
6767 #[test]
6768 fn required_tool_choice_uses_tool_schema_response_format_without_extra_prompt_instruction() {
6769 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6770 "model": "served-alias",
6771 "messages": [{"role": "user", "content": "Call capture_quality_marker."}],
6772 "tools": [{
6773 "type": "function",
6774 "function": {
6775 "name": "capture_quality_marker",
6776 "description": "Record one marker.",
6777 "parameters": {
6778 "type": "object",
6779 "properties": {
6780 "marker": {"type": "string", "enum": ["ferrum0401"]},
6781 "checksum": {"type": "string", "enum": ["S0004"]}
6782 },
6783 "required": ["marker", "checksum"]
6784 }
6785 }
6786 }],
6787 "tool_choice": "required"
6788 }))
6789 .expect("tool request parses");
6790
6791 validate_chat_request(&request).expect("tool request validates");
6792 let internal = convert_chat_request(&request).expect("convert");
6793
6794 assert!(
6795 !internal.prompt.contains(
6796 "Output only a single JSON object containing the selected function arguments"
6797 ),
6798 "{}",
6799 internal.prompt
6800 );
6801 assert!(
6802 internal.prompt.contains("\"tool_choice\":\"required\""),
6803 "{}",
6804 internal.prompt
6805 );
6806 assert_eq!(internal.metadata["openai_tool_choice"], "required");
6807 match internal.sampling_params.response_format {
6808 ferrum_types::ResponseFormat::JsonSchema(ref schema) => {
6809 assert!(schema.contains(r#""enum":["ferrum0401"]"#), "{schema}");
6810 assert!(schema.contains(r#""enum":["S0004"]"#), "{schema}");
6811 }
6812 ref other => panic!("expected forced tool json schema, got {other:?}"),
6813 }
6814 }
6815
6816 #[test]
6817 fn omitted_single_unrelated_tool_keeps_text_response_format() {
6818 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6819 "model": "served-alias",
6820 "messages": [{"role": "user", "content": "讲一个短笑话。"}],
6821 "tools": [{
6822 "type": "function",
6823 "function": {
6824 "name": "get_weather",
6825 "description": "查询指定城市的当前天气",
6826 "parameters": {
6827 "type": "object",
6828 "properties": {"city": {"type": "string"}},
6829 "required": ["city"]
6830 }
6831 }
6832 }]
6833 }))
6834 .expect("tool request parses");
6835
6836 validate_chat_request(&request).expect("tool request validates");
6837 let internal = convert_chat_request(&request).expect("convert");
6838 assert_eq!(
6839 internal.sampling_params.response_format,
6840 ferrum_types::ResponseFormat::Text
6841 );
6842 }
6843
6844 #[test]
6845 fn tool_choice_none_omits_tools_from_model_template_prompt() {
6846 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6847 "model": "served-alias",
6848 "messages": [
6849 {"role": "user", "content": "Use the weather tool if needed."},
6850 {
6851 "role": "assistant",
6852 "content": null,
6853 "tool_calls": [{
6854 "id": "call_1",
6855 "type": "function",
6856 "function": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
6857 }]
6858 },
6859 {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp\":22}"}
6860 ],
6861 "tools": [{
6862 "type": "function",
6863 "function": {"name": "weather", "parameters": {"type": "object"}}
6864 }],
6865 "tool_choice": "none"
6866 }))
6867 .expect("tool_choice none request parses");
6868 let template = ModelChatTemplate::new(
6869 "{% 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 %}",
6870 "tool-choice-none-template",
6871 );
6872
6873 validate_chat_request(&request).expect("tool_choice none request validates");
6874 let internal = convert_chat_request_with_template_model(
6875 &request,
6876 "served-template-model",
6877 Some(&template),
6878 )
6879 .expect("convert");
6880 assert!(
6881 !internal.prompt.contains("<tools>"),
6882 "tool_choice none must not expose tools to the model template: {}",
6883 internal.prompt
6884 );
6885 assert!(internal.prompt.contains("[tool]"), "{}", internal.prompt);
6886 assert_eq!(
6887 internal.metadata["openai_tools"][0]["function"]["name"],
6888 "weather"
6889 );
6890 assert_eq!(internal.metadata["openai_tool_choice"], "none");
6891 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6892 panic!("expected structured chat api_request");
6893 };
6894 assert_eq!(api.tools[0].function.name, "weather");
6895 assert_eq!(
6896 api.tool_choice,
6897 Some(ferrum_types::ApiToolChoice::Mode("none".into()))
6898 );
6899 }
6900
6901 #[test]
6902 fn specific_tool_choice_parses_into_structured_api_request() {
6903 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6904 "model": "qwen3",
6905 "messages": [{"role": "user", "content": "Use the selected tool."}],
6906 "tools": [
6907 {
6908 "type": "function",
6909 "function": {"name": "weather", "parameters": {"type": "object"}}
6910 },
6911 {
6912 "type": "function",
6913 "function": {"name": "calendar", "parameters": {"type": "object"}}
6914 }
6915 ],
6916 "tool_choice": {
6917 "type": "function",
6918 "function": {"name": "weather"}
6919 }
6920 }))
6921 .expect("specific tool_choice request parses");
6922
6923 validate_chat_request(&request).expect("specific tool_choice validates");
6924 let internal = convert_chat_request(&request).expect("convert");
6925 assert!(internal.prompt.contains("\"tool_choice\":{"));
6926 assert!(internal.prompt.contains("\"name\":\"weather\""));
6927 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
6928 panic!("expected structured chat api_request");
6929 };
6930 assert_eq!(
6931 api.tool_choice,
6932 Some(ferrum_types::ApiToolChoice::Function {
6933 tool_type: "function".to_string(),
6934 function: ferrum_types::ApiToolChoiceFunction {
6935 name: "weather".to_string()
6936 },
6937 })
6938 );
6939
6940 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
6941 "model": "qwen3",
6942 "messages": [{"role": "user", "content": "Use the selected tool."}],
6943 "tools": [{
6944 "type": "function",
6945 "function": {"name": "weather", "parameters": {"type": "object"}}
6946 }],
6947 "tool_choice": {
6948 "type": "function",
6949 "function": {"name": "calendar"}
6950 }
6951 }))
6952 .expect("invalid specific tool_choice request parses");
6953 let err = validate_chat_request(&invalid).expect_err("undeclared tool should reject");
6954 match err {
6955 ServerError::InvalidRequest { param, .. } => {
6956 assert_eq!(param.as_deref(), Some("tool_choice"));
6957 }
6958 other => panic!("expected invalid_request_error for tool_choice, got {other:?}"),
6959 }
6960 }
6961
6962 #[test]
6963 fn legacy_function_role_messages_parse_into_structured_api_request() {
6964 let request: ChatCompletionsRequest = serde_json::from_value(json!({
6965 "model": "mystery-model",
6966 "messages": [
6967 {"role": "user", "content": "Call weather."},
6968 {
6969 "role": "assistant",
6970 "content": null,
6971 "function_call": {"name": "weather", "arguments": "{\"city\":\"Paris\"}"}
6972 },
6973 {"role": "function", "name": "weather", "content": "{\"forecast\":\"sunny\"}"}
6974 ],
6975 "functions": [{
6976 "name": "weather",
6977 "parameters": {
6978 "type": "object",
6979 "properties": {"city": {"type": "string"}},
6980 "required": ["city"]
6981 }
6982 }],
6983 "function_call": "auto"
6984 }))
6985 .expect("legacy function request parses");
6986
6987 validate_chat_request(&request).expect("legacy function request validates");
6988 let internal = convert_chat_request(&request).expect("convert");
6989 assert!(
6990 internal
6991 .prompt
6992 .contains("<|function|>\n{\"forecast\":\"sunny\"}</s>"),
6993 "legacy function role should be preserved in fallback template: {}",
6994 internal.prompt
6995 );
6996 assert_eq!(
6997 internal.metadata["openai_legacy_functions"][0]["name"],
6998 "weather"
6999 );
7000 assert_eq!(internal.metadata["openai_legacy_function_call"], "auto");
7001 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
7002 panic!("expected structured chat api_request");
7003 };
7004 assert_eq!(api.messages.len(), 3);
7005 assert_eq!(api.messages[2].role, ferrum_types::ApiMessageRole::Function);
7006 assert_eq!(api.messages[2].name.as_deref(), Some("weather"));
7007 assert_eq!(
7008 api.messages[1]
7009 .function_call
7010 .as_ref()
7011 .map(|call| call.name.as_str()),
7012 Some("weather")
7013 );
7014 assert_eq!(api.legacy_functions[0].name, "weather");
7015 assert_eq!(
7016 api.legacy_function_call,
7017 Some(ferrum_types::ApiFunctionCallChoice::Mode("auto".into()))
7018 );
7019 }
7020
7021 #[test]
7022 fn specific_legacy_function_call_parses_into_structured_api_request() {
7023 let request: ChatCompletionsRequest = serde_json::from_value(json!({
7024 "model": "mystery-model",
7025 "messages": [{"role": "user", "content": "Use the selected function."}],
7026 "functions": [
7027 {"name": "weather", "parameters": {"type": "object"}},
7028 {"name": "calendar", "parameters": {"type": "object"}}
7029 ],
7030 "function_call": {"name": "weather"}
7031 }))
7032 .expect("specific function_call request parses");
7033
7034 validate_chat_request(&request).expect("specific function_call validates");
7035 let internal = convert_chat_request(&request).expect("convert");
7036 assert!(internal.prompt.contains("\"function_call\":{"));
7037 assert!(internal.prompt.contains("\"name\":\"weather\""));
7038 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
7039 panic!("expected structured chat api_request");
7040 };
7041 assert_eq!(
7042 api.legacy_function_call,
7043 Some(ferrum_types::ApiFunctionCallChoice::Function {
7044 name: "weather".to_string(),
7045 })
7046 );
7047
7048 let invalid: ChatCompletionsRequest = serde_json::from_value(json!({
7049 "model": "mystery-model",
7050 "messages": [{"role": "user", "content": "Use the selected function."}],
7051 "functions": [{"name": "weather", "parameters": {"type": "object"}}],
7052 "function_call": {"name": "calendar"}
7053 }))
7054 .expect("invalid specific function_call request parses");
7055 let err = validate_chat_request(&invalid).expect_err("undeclared function should reject");
7056 match err {
7057 ServerError::InvalidRequest { param, .. } => {
7058 assert_eq!(param.as_deref(), Some("function_call"));
7059 }
7060 other => panic!("expected invalid_request_error for function_call, got {other:?}"),
7061 }
7062 }
7063
7064 #[test]
7065 fn stream_text_delta_handles_unicode_boundaries() {
7066 let mut sent_len = 0usize;
7067 assert_eq!(stream_text_delta("你好", &mut sent_len), "你好");
7068 assert_eq!(sent_len, "你好".len());
7069 assert_eq!(stream_text_delta("你好世界", &mut sent_len), "世界");
7070 assert_eq!(sent_len, "你好世界".len());
7071 }
7072
7073 #[test]
7074 fn stream_text_delta_recovers_from_non_boundary_offset() {
7075 let mut sent_len = 1usize;
7076 assert_eq!(stream_text_delta("你好", &mut sent_len), "");
7077 assert_eq!(sent_len, "你好".len());
7078 }
7079
7080 #[test]
7081 fn assistant_tool_call_serializes_openai_shape() {
7082 let message = ChatMessage {
7083 role: MessageRole::Assistant,
7084 content: String::new(),
7085 reasoning: None,
7086 name: None,
7087 tool_calls: Some(vec![ChatToolCall {
7088 index: None,
7089 id: "call_1".to_string(),
7090 tool_type: "function".to_string(),
7091 function: ChatFunctionCall {
7092 name: "weather".to_string(),
7093 arguments: "{\"city\":\"Paris\"}".to_string(),
7094 },
7095 }]),
7096 tool_call_id: None,
7097 function_call: None,
7098 };
7099 let value = serde_json::to_value(message).expect("serialize");
7100 assert_eq!(value["role"], "assistant");
7101 assert_eq!(value["tool_calls"][0]["type"], "function");
7102 assert_eq!(value["tool_calls"][0]["function"]["name"], "weather");
7103 }
7104
7105 #[test]
7106 fn unsupported_multimodal_content_is_not_silently_dropped() {
7107 let err = serde_json::from_value::<ChatCompletionsRequest>(json!({
7108 "model": "stub-model",
7109 "messages": [{
7110 "role": "user",
7111 "content": [
7112 {"type": "text", "text": "describe this"},
7113 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
7114 ]
7115 }]
7116 }))
7117 .expect_err("unsupported content part should fail parsing");
7118 assert!(
7119 err.to_string()
7120 .contains("unsupported message content part type"),
7121 "unexpected error: {err}"
7122 );
7123 }
7124
7125 #[tokio::test]
7126 async fn completions_endpoint_uses_stub_engine() {
7127 let request = CompletionsRequest {
7128 model: "stub-model".to_string(),
7129 prompt: CompletionPrompt::Text("complete me".to_string()),
7130 max_tokens: Some(8),
7131 temperature: Some(0.0),
7132 top_p: None,
7133 n: None,
7134 stream: None,
7135 stop: None,
7136 logprobs: None,
7137 logit_bias: None,
7138 };
7139 let response = completions_handler(State(state_with_stub("done")), Ok(Json(request)))
7140 .await
7141 .expect("completion response");
7142 assert_eq!(response.status(), AxumStatusCode::OK);
7143 let body = response_json(response).await;
7144 assert_eq!(body["object"], "text_completion");
7145 assert_eq!(body["choices"][0]["text"], "done");
7146 assert_eq!(body["usage"]["prompt_tokens"], 7);
7147 assert_eq!(body["usage"]["completion_tokens"], 2);
7148 }
7149
7150 #[tokio::test]
7151 async fn route_completions_rejects_non_string_prompt_with_field_param() {
7152 for prompt in [
7153 json!(["a", "b"]),
7154 json!({"text": "complete me"}),
7155 Value::Null,
7156 ] {
7157 let response = post_json(
7158 router_with_stub("unused"),
7159 "/v1/completions",
7160 json!({
7161 "model": "stub-model",
7162 "prompt": prompt
7163 }),
7164 )
7165 .await;
7166 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7167 let body = response_json(response).await;
7168 assert_eq!(body["error"]["type"], "invalid_request_error");
7169 assert_eq!(body["error"]["param"], "prompt");
7170 }
7171
7172 let response = post_json(
7173 router_with_stub("unused"),
7174 "/v1/completions",
7175 json!({"model": "stub-model"}),
7176 )
7177 .await;
7178 assert_eq!(response.status(), AxumStatusCode::BAD_REQUEST);
7179 let body = response_json(response).await;
7180 assert_eq!(body["error"]["type"], "invalid_request_error");
7181 assert_eq!(body["error"]["param"], "prompt");
7182 }
7183
7184 #[tokio::test]
7185 async fn stream_options_without_stream_is_invalid() {
7186 let request = chat_request(json!({"stream_options": {"include_usage": true}}));
7187 let err = chat_completions_handler(
7188 State(state_with_stub("unused")),
7189 HeaderMap::new(),
7190 Ok(Json(request)),
7191 )
7192 .await
7193 .expect_err("stream_options without stream should reject");
7194 let (status, body) = error_json(err).await;
7195 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
7196 assert_eq!(body["error"]["param"], "stream_options");
7197 assert_eq!(body["error"]["type"], "invalid_request_error");
7198 }
7199
7200 #[tokio::test]
7201 async fn json_object_strips_single_markdown_fence_as_best_effort_repair() {
7202 let request = chat_request(json!({
7203 "response_format": {"type": "json_object"}
7204 }));
7205 let response = chat_completions_handler(
7206 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
7207 HeaderMap::new(),
7208 Ok(Json(request)),
7209 )
7210 .await
7211 .expect("json_object response");
7212 assert_eq!(response.status(), AxumStatusCode::OK);
7213 let body = response_json(response).await;
7214 let content = body["choices"][0]["message"]["content"]
7215 .as_str()
7216 .expect("content string");
7217 assert_eq!(content, "{\"answer\":\"yes\"}");
7218 let parsed: serde_json::Value = serde_json::from_str(content).expect("parse json_object");
7219 assert_eq!(parsed["answer"], "yes");
7220 }
7221
7222 #[tokio::test]
7223 async fn streaming_json_object_buffers_thinking_and_emits_clean_json_content() {
7224 let response = post_json(
7225 router_with_stub_stream_chunks(&[
7226 "<think>\n好的,我需要输出 JSON。",
7227 "\n</think>\n\n",
7228 "{\"name\":\"李四\",\"age\":30}",
7229 ]),
7230 "/v1/chat/completions",
7231 json!({
7232 "model": "stub-model",
7233 "messages": [{"role": "user", "content": "输出JSON(name,age):李四,30岁"}],
7234 "stream": true,
7235 "response_format": {"type": "json_object"}
7236 }),
7237 )
7238 .await;
7239 assert_eq!(response.status(), AxumStatusCode::OK);
7240 let body = response_text(response).await;
7241 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7242 assert!(
7243 body.contains(r#""content":"{\"name\":\"李四\",\"age\":30}""#),
7244 "stream should emit clean JSON content: {body}"
7245 );
7246 assert!(
7247 body.contains(r#""reasoning":"\n好的,我需要输出 JSON。\n""#),
7248 "stream should keep thinking in reasoning field: {body}"
7249 );
7250 assert!(
7251 !body.contains(r#""content":"<think"#)
7252 && !body.contains(r#""content":"好的"#)
7253 && !body.contains(r#""content":"我需要"#),
7254 "thinking text must not leak as streamed content: {body}"
7255 );
7256 }
7257
7258 #[tokio::test]
7259 async fn json_object_remains_best_effort_not_strict_validation() {
7260 let request = chat_request(json!({
7261 "response_format": {"type": "json_object"}
7262 }));
7263 let response = chat_completions_handler(
7264 State(state_with_stub("not json")),
7265 HeaderMap::new(),
7266 Ok(Json(request)),
7267 )
7268 .await
7269 .expect("json_object remains best-effort");
7270 assert_eq!(response.status(), AxumStatusCode::OK);
7271 let body = response_json(response).await;
7272 assert_eq!(body["choices"][0]["message"]["content"], "not json");
7273 }
7274
7275 #[tokio::test]
7276 async fn unsupported_strict_json_schema_is_rejected_at_boundary() {
7277 let request = chat_request(json!({
7278 "response_format": {
7279 "type": "json_schema",
7280 "json_schema": {
7281 "name": "unsupported",
7282 "strict": true,
7283 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
7284 }
7285 }
7286 }));
7287 let err = chat_completions_handler(
7288 State(state_with_stub("unused")),
7289 HeaderMap::new(),
7290 Ok(Json(request)),
7291 )
7292 .await
7293 .expect_err("unsupported strict schema should reject");
7294 let (status, body) = error_json(err).await;
7295 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
7296 assert_eq!(body["error"]["param"], "response_format.json_schema");
7297 assert_eq!(body["error"]["type"], "invalid_request_error");
7298 }
7299
7300 #[tokio::test]
7301 async fn missing_json_schema_schema_rejects_with_field_param() {
7302 let request = chat_request(json!({
7303 "response_format": {
7304 "type": "json_schema",
7305 "json_schema": {
7306 "name": "missing_schema",
7307 "strict": true
7308 }
7309 }
7310 }));
7311 let err = chat_completions_handler(
7312 State(state_with_stub("unused")),
7313 HeaderMap::new(),
7314 Ok(Json(request)),
7315 )
7316 .await
7317 .expect_err("missing strict schema should reject");
7318 let (status, body) = error_json(err).await;
7319 assert_eq!(status, AxumStatusCode::BAD_REQUEST);
7320 assert_eq!(body["error"]["param"], "response_format.json_schema");
7321 assert_eq!(body["error"]["type"], "invalid_request_error");
7322 assert!(body["error"]["message"]
7323 .as_str()
7324 .unwrap()
7325 .contains("schema is required"));
7326 }
7327
7328 #[test]
7329 fn non_strict_json_schema_is_preserved_but_not_hard_masked() {
7330 let request = chat_request(json!({
7331 "response_format": {
7332 "type": "json_schema",
7333 "json_schema": {
7334 "name": "best_effort",
7335 "strict": false,
7336 "schema": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
7337 }
7338 }
7339 }));
7340
7341 validate_chat_request(&request).expect("non-strict schema should not boundary reject");
7342 let internal = convert_chat_request(&request).expect("convert non-strict schema");
7343 assert!(
7344 internal
7345 .prompt
7346 .contains("response_format requires a single valid JSON object"),
7347 "response_format instruction should reach the model prompt: {}",
7348 internal.prompt
7349 );
7350 assert!(
7351 internal.prompt.contains("\"oneOf\""),
7352 "schema should reach the model prompt: {}",
7353 internal.prompt
7354 );
7355 assert_eq!(
7356 internal.sampling_params.response_format,
7357 ferrum_types::ResponseFormat::Text,
7358 "non-strict json_schema must stay best-effort instead of enabling hard guided decode"
7359 );
7360 let Some(ferrum_types::ApiRequest::Chat(api)) = internal.api_request.as_ref() else {
7361 panic!("expected structured chat api_request");
7362 };
7363 assert_eq!(
7364 api.response_format
7365 .as_ref()
7366 .and_then(|format| format.json_schema.as_ref())
7367 .and_then(|schema| schema.strict),
7368 Some(false)
7369 );
7370 }
7371
7372 #[test]
7373 fn json_object_response_format_instruction_reaches_model_prompt() {
7374 let request = chat_request(json!({
7375 "response_format": {"type": "json_object"}
7376 }));
7377
7378 let internal = convert_chat_request(&request).expect("convert json_object");
7379 assert!(
7380 internal
7381 .prompt
7382 .contains("response_format requires a single valid JSON object"),
7383 "response_format instruction should reach the model prompt: {}",
7384 internal.prompt
7385 );
7386 assert!(
7387 internal.prompt.contains("Output only JSON"),
7388 "JSON-only instruction should reach the model prompt: {}",
7389 internal.prompt
7390 );
7391 assert_eq!(
7392 internal.sampling_params.response_format,
7393 ferrum_types::ResponseFormat::Text,
7394 "json_object response_format should use prompt instruction and final JSON cleanup, not engine-wide JSON soft bias"
7395 );
7396 }
7397
7398 #[test]
7399 fn strict_json_schema_response_format_uses_text_sampling_mode() {
7400 let request = chat_request(json!({
7401 "response_format": {
7402 "type": "json_schema",
7403 "json_schema": {
7404 "name": "answer",
7405 "strict": true,
7406 "schema": {
7407 "type": "object",
7408 "properties": {"answer": {"type": "string"}},
7409 "required": ["answer"]
7410 }
7411 }
7412 }
7413 }));
7414
7415 let internal = convert_chat_request(&request).expect("convert strict json_schema");
7416 assert!(
7417 internal
7418 .prompt
7419 .contains("response_format requires a single valid JSON object"),
7420 "response_format instruction should reach the model prompt: {}",
7421 internal.prompt
7422 );
7423 assert_eq!(
7424 internal.sampling_params.response_format,
7425 ferrum_types::ResponseFormat::Text,
7426 "strict json_schema is validated after generation instead of applying JSON soft bias during Qwen3 thinking"
7427 );
7428 }
7429
7430 #[tokio::test]
7431 async fn strict_json_schema_validates_non_streaming_response() {
7432 let request = chat_request(json!({
7433 "response_format": {
7434 "type": "json_schema",
7435 "json_schema": {
7436 "name": "answer",
7437 "strict": true,
7438 "schema": {
7439 "type": "object",
7440 "properties": {"answer": {"type": "string"}},
7441 "required": ["answer"]
7442 }
7443 }
7444 }
7445 }));
7446 let response = chat_completions_handler(
7447 State(state_with_stub("{\"answer\":\"yes\"}")),
7448 HeaderMap::new(),
7449 Ok(Json(request)),
7450 )
7451 .await
7452 .expect("strict response");
7453 assert_eq!(response.status(), AxumStatusCode::OK);
7454 let body = response_json(response).await;
7455 assert_eq!(
7456 body["choices"][0]["message"]["content"],
7457 "{\"answer\":\"yes\"}"
7458 );
7459 }
7460
7461 #[tokio::test]
7462 async fn strict_json_schema_validates_non_streaming_response_after_reasoning_block() {
7463 let request = chat_request(json!({
7464 "response_format": {
7465 "type": "json_schema",
7466 "json_schema": {
7467 "name": "answer",
7468 "strict": true,
7469 "schema": {
7470 "type": "object",
7471 "properties": {"answer": {"type": "string"}},
7472 "required": ["answer"]
7473 }
7474 }
7475 }
7476 }));
7477 let response = chat_completions_handler(
7478 State(state_with_stub(
7479 "<think>\nreasoning\n</think>\n\n{\"answer\":\"yes\"}",
7480 )),
7481 HeaderMap::new(),
7482 Ok(Json(request)),
7483 )
7484 .await
7485 .expect("strict response with reasoning");
7486 assert_eq!(response.status(), AxumStatusCode::OK);
7487 let body = response_json(response).await;
7488 assert_eq!(
7489 body["choices"][0]["message"]["content"],
7490 "{\"answer\":\"yes\"}"
7491 );
7492 assert_eq!(body["choices"][0]["message"]["reasoning"], "\nreasoning\n");
7493 }
7494
7495 #[tokio::test]
7496 async fn strict_json_schema_validates_streaming_final_response() {
7497 let response = post_json(
7498 router_with_stub("{\"answer\":\"yes\"}"),
7499 "/v1/chat/completions",
7500 json!({
7501 "model": "stub-model",
7502 "messages": [{"role": "user", "content": "Return an answer object."}],
7503 "stream": true,
7504 "response_format": {
7505 "type": "json_schema",
7506 "json_schema": {
7507 "name": "answer",
7508 "strict": true,
7509 "schema": {
7510 "type": "object",
7511 "properties": {"answer": {"type": "string"}},
7512 "required": ["answer"]
7513 }
7514 }
7515 }
7516 }),
7517 )
7518 .await;
7519 assert_eq!(response.status(), AxumStatusCode::OK);
7520 let body = response_text(response).await;
7521 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7522 assert!(
7523 body.contains("\\\"answer\\\":\\\"yes\\\""),
7524 "strict streaming content missing: {body}"
7525 );
7526 assert!(
7527 !body.contains("\"error\""),
7528 "valid strict streaming response should not emit error: {body}"
7529 );
7530 }
7531
7532 #[tokio::test]
7533 async fn strict_json_schema_validates_streaming_final_response_after_reasoning_block() {
7534 let response = post_json(
7535 router_with_stub_stream_chunks(&[
7536 "<think>\nreasoning",
7537 "\n</think>\n\n",
7538 "{\"answer\":\"yes\"}",
7539 ]),
7540 "/v1/chat/completions",
7541 json!({
7542 "model": "stub-model",
7543 "messages": [{"role": "user", "content": "Return an answer object."}],
7544 "stream": true,
7545 "response_format": {
7546 "type": "json_schema",
7547 "json_schema": {
7548 "name": "answer",
7549 "strict": true,
7550 "schema": {
7551 "type": "object",
7552 "properties": {"answer": {"type": "string"}},
7553 "required": ["answer"]
7554 }
7555 }
7556 }
7557 }),
7558 )
7559 .await;
7560 assert_eq!(response.status(), AxumStatusCode::OK);
7561 let body = response_text(response).await;
7562 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7563 assert!(
7564 body.contains("\\\"answer\\\":\\\"yes\\\""),
7565 "strict streaming content missing: {body}"
7566 );
7567 assert!(
7568 body.contains(r#""reasoning":"\nreasoning\n""#),
7569 "strict streaming should keep reasoning separate: {body}"
7570 );
7571 assert!(
7572 !body.contains("\"error\""),
7573 "valid strict streaming response should not emit error: {body}"
7574 );
7575 }
7576
7577 #[tokio::test]
7578 async fn strict_json_schema_invalid_streaming_output_emits_error_event() {
7579 let response = post_json(
7580 router_with_stub("not json"),
7581 "/v1/chat/completions",
7582 json!({
7583 "model": "stub-model",
7584 "messages": [{"role": "user", "content": "Return an answer object."}],
7585 "stream": true,
7586 "response_format": {
7587 "type": "json_schema",
7588 "json_schema": {
7589 "name": "answer",
7590 "strict": true,
7591 "schema": {
7592 "type": "object",
7593 "properties": {"answer": {"type": "string"}},
7594 "required": ["answer"]
7595 }
7596 }
7597 }
7598 }),
7599 )
7600 .await;
7601 assert_eq!(response.status(), AxumStatusCode::OK);
7602 let body = response_text(response).await;
7603 assert!(body.contains("data: [DONE]"), "missing DONE: {body}");
7604 assert!(
7605 body.contains("\"type\":\"internal_server_error\""),
7606 "strict streaming validation failure should emit OpenAI error: {body}"
7607 );
7608 assert!(
7609 body.contains("\"param\":\"response_format.json_schema\""),
7610 "strict streaming validation error should identify schema param: {body}"
7611 );
7612 assert!(
7613 body.contains("invalid JSON"),
7614 "strict streaming validation should report invalid JSON: {body}"
7615 );
7616 assert!(
7617 !body.contains("not json"),
7618 "strict streaming must not emit invalid partial deltas before validation failure: {body}"
7619 );
7620 }
7621
7622 #[tokio::test]
7623 async fn route_strict_json_schema_supported_schema_passes_100_runs() {
7624 let request_body = json!({
7625 "model": "stub-model",
7626 "messages": [{"role": "user", "content": "Return an answer object."}],
7627 "response_format": {
7628 "type": "json_schema",
7629 "json_schema": {
7630 "name": "answer",
7631 "strict": true,
7632 "schema": {
7633 "type": "object",
7634 "properties": {"answer": {"type": "string"}},
7635 "required": ["answer"]
7636 }
7637 }
7638 }
7639 });
7640 let router = router_with_stub("{\"answer\":\"yes\"}");
7641 for run in 0..100 {
7642 let response =
7643 post_json(router.clone(), "/v1/chat/completions", request_body.clone()).await;
7644 assert_eq!(
7645 response.status(),
7646 AxumStatusCode::OK,
7647 "strict schema run {run} returned non-200"
7648 );
7649 let body = response_json(response).await;
7650 let content = body["choices"][0]["message"]["content"]
7651 .as_str()
7652 .unwrap_or("");
7653 assert_eq!(
7654 content, "{\"answer\":\"yes\"}",
7655 "strict schema run {run} returned unexpected content"
7656 );
7657 let parsed: serde_json::Value =
7658 serde_json::from_str(content).expect("strict content JSON");
7659 assert_eq!(parsed["answer"], "yes");
7660 }
7661 }
7662
7663 #[test]
7664 fn cache_metrics_use_engine_real_kv_snapshot_when_available() {
7665 let cache = CacheRuntimeState::default();
7666 let policy = CachePolicy {
7667 prefix_cache_enabled: true,
7668 session_cache_mode: "memory".to_string(),
7669 session_cache_max_entries: 128,
7670 session_cache_max_tokens: 4096,
7671 };
7672 cache.record_prefix_prompt("alpha beta gamma", &policy);
7673 cache.record_prefix_prompt("alpha beta delta", &policy);
7674
7675 let engine_snapshot = json!({
7676 "position": "real-kv-reuse",
7677 "source": "llama-family-paged-block-prefix-cache",
7678 "enabled": true,
7679 "hits": 7,
7680 "misses": 3,
7681 "evictions": 1,
7682 "saved_prefill_tokens": 64,
7683 "entries": 5,
7684 "bytes": 8192,
7685 "block_size": 16,
7686 "kv_dtype": "fp16",
7687 "selected_pipeline_mode": "batch",
7688 "selected_stage_bridge": "host",
7689 "stage_count": 2,
7690 });
7691
7692 let health = cache.health_json(&policy, Some(&engine_snapshot));
7693 let prefix = &health["prefix_cache"];
7694 assert_eq!(prefix["position"], "real-kv-reuse");
7695 assert_eq!(prefix["source"], "llama-family-paged-block-prefix-cache");
7696 assert_eq!(prefix["hits"], 7);
7697 assert_eq!(prefix["misses"], 3);
7698 assert_eq!(prefix["evictions"], 1);
7699 assert_eq!(prefix["saved_prefill_tokens"], 64);
7700 assert_eq!(prefix["entries"], 5);
7701 assert_eq!(prefix["bytes"], 8192);
7702 assert_eq!(prefix["block_size"], 16);
7703 assert_eq!(prefix["kv_dtype"], "fp16");
7704 assert_eq!(prefix["selected_pipeline_mode"], "batch");
7705 assert_eq!(prefix["selected_stage_bridge"], "host");
7706 assert_eq!(prefix["stage_count"], 2);
7707
7708 let metrics = cache.prometheus_metrics(Some(&engine_snapshot));
7709 assert!(metrics.contains("ferrum_prefix_cache_hits_total 7\n"));
7710 assert!(metrics.contains("ferrum_prefix_cache_misses_total 3\n"));
7711 assert!(metrics.contains("ferrum_prefix_cache_saved_prefill_tokens_total 64\n"));
7712 assert!(metrics.contains("ferrum_prefix_cache_entries 5\n"));
7713 assert!(metrics.contains("ferrum_prefix_cache_bytes 8192\n"));
7714 }
7715
7716 #[tokio::test]
7717 async fn strict_json_schema_invalid_model_output_fails_before_response() {
7718 let request = chat_request(json!({
7719 "response_format": {
7720 "type": "json_schema",
7721 "json_schema": {
7722 "name": "answer",
7723 "strict": true,
7724 "schema": {
7725 "type": "object",
7726 "properties": {"answer": {"type": "string"}},
7727 "required": ["answer"]
7728 }
7729 }
7730 }
7731 }));
7732 let err = chat_completions_handler(
7733 State(state_with_stub("not json")),
7734 HeaderMap::new(),
7735 Ok(Json(request)),
7736 )
7737 .await
7738 .expect_err("invalid strict response should fail");
7739 let (status, body) = error_json(err).await;
7740 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
7741 assert_eq!(body["error"]["type"], "internal_server_error");
7742 assert!(body["error"]["message"]
7743 .as_str()
7744 .unwrap()
7745 .contains("json_schema.strict"));
7746 }
7747
7748 #[tokio::test]
7749 async fn strict_json_schema_does_not_rely_on_markdown_fence_stripping() {
7750 let request = chat_request(json!({
7751 "response_format": {
7752 "type": "json_schema",
7753 "json_schema": {
7754 "name": "answer",
7755 "strict": true,
7756 "schema": {
7757 "type": "object",
7758 "properties": {"answer": {"type": "string"}},
7759 "required": ["answer"]
7760 }
7761 }
7762 }
7763 }));
7764 let err = chat_completions_handler(
7765 State(state_with_stub("```json\n{\"answer\":\"yes\"}\n```")),
7766 HeaderMap::new(),
7767 Ok(Json(request)),
7768 )
7769 .await
7770 .expect_err("strict schema should fail fenced JSON instead of repairing it");
7771 let (status, body) = error_json(err).await;
7772 assert_eq!(status, AxumStatusCode::INTERNAL_SERVER_ERROR);
7773 assert_eq!(body["error"]["type"], "internal_server_error");
7774 assert!(body["error"]["message"]
7775 .as_str()
7776 .unwrap()
7777 .contains("json_schema.strict: invalid JSON"));
7778 }
7779}