1use std::path::{Path, PathBuf};
28
29pub const METAL_FIRST_SUCCESS_MODEL: &str = "qwen3.5:4b-q4_k_m";
32pub const CUDA_FIRST_SUCCESS_MODEL: &str = "qwen3.5:4b";
33
34pub fn first_success_model_help(command: &str) -> String {
36 format!(
37 "no model selected. Choose one explicitly:\n Metal: ferrum {command} {METAL_FIRST_SUCCESS_MODEL}\n CUDA: ferrum {command} {CUDA_FIRST_SUCCESS_MODEL}\nRun `ferrum doctor` to inspect this binary before downloading a model."
38 )
39}
40use std::sync::Arc;
41
42use clap::Args;
43use ferrum_interfaces::vnext::{
44 ModelSourceKind, OriginalModelSource, OriginalModelSources, ProductModelSourceIdentity,
45};
46use ferrum_models::source::{ModelFormat, ResolvedModelSource};
47use ferrum_models::vnext::{
48 huggingface_snapshot_identity, open_registered_product_sources, ProductionModelSourceBundle,
49 ProductionWeightArtifact,
50};
51use ferrum_server::chat_template::ModelChatTemplate;
52use ferrum_types::{
53 EngineConfig, FerrumError, ModelId, ModelSource, Result, RuntimeConfigEntry,
54 RuntimeConfigSnapshot, RuntimeConfigSource,
55};
56use sha2::{Digest, Sha256};
57
58use crate::config::CliConfig;
59use crate::gpu_mem_autosize::{apply_auto_size_with_profile, AutoSizeProfile};
60
61#[derive(Args, Debug, Clone, Default)]
64pub struct ProductSourceArgs {
65 #[arg(long, value_name = "DIR")]
69 pub semantic_source: Option<PathBuf>,
70
71 #[arg(long, value_name = "DIR")]
73 pub tokenizer_source: Option<PathBuf>,
74}
75
76pub fn hf_cache_dir(config: &CliConfig) -> PathBuf {
78 if let Ok(hf_home) = std::env::var("HF_HOME") {
79 return PathBuf::from(hf_home);
80 }
81 PathBuf::from(shellexpand::tilde(&config.models.download.hf_cache_dir).as_ref())
82}
83
84pub fn detect_format(path: &Path) -> ModelFormat {
86 if path.is_file()
87 && path
88 .extension()
89 .map(|e| e.eq_ignore_ascii_case("gguf"))
90 .unwrap_or(false)
91 {
92 return ModelFormat::GGUF;
93 }
94 if path.join("model.safetensors").exists() || path.join("model.safetensors.index.json").exists()
95 {
96 ModelFormat::SafeTensors
97 } else if path.join("pytorch_model.bin").exists() {
98 ModelFormat::PyTorchBin
99 } else {
100 ModelFormat::Unknown
101 }
102}
103
104pub fn looks_like_gguf_path(model: &str) -> bool {
106 let p = PathBuf::from(model);
107 p.extension()
108 .map(|e| e.eq_ignore_ascii_case("gguf"))
109 .unwrap_or(false)
110 && p.is_file()
111}
112
113pub fn public_model_id(source: &ResolvedModelSource) -> String {
120 if let Some(identity) = huggingface_snapshot_identity(&source.local_path) {
121 return identity.repository_id;
122 }
123 match source.format {
124 ModelFormat::GGUF => source
125 .local_path
126 .file_stem()
127 .map(|value| value.to_string_lossy().into_owned())
128 .unwrap_or_else(|| source.original.clone()),
129 _ if source.local_path == Path::new(&source.original) => source
130 .local_path
131 .file_name()
132 .map(|value| value.to_string_lossy().into_owned())
133 .unwrap_or_else(|| source.original.clone()),
134 _ => source.original.clone(),
135 }
136}
137
138pub fn resolve_model_alias(name: &str) -> String {
140 match name.to_lowercase().as_str() {
141 "tinyllama" | "tiny" => "TinyLlama/TinyLlama-1.1B-Chat-v1.0".to_string(),
142 "qwen2.5:0.5b" | "qwen:0.5b" => "Qwen/Qwen2.5-0.5B-Instruct".to_string(),
143 "qwen2.5:1.5b" | "qwen:1.5b" => "Qwen/Qwen2.5-1.5B-Instruct".to_string(),
144 "qwen2.5:3b" | "qwen:3b" => "Qwen/Qwen2.5-3B-Instruct".to_string(),
145 "qwen2.5:7b" | "qwen:7b" => "Qwen/Qwen2.5-7B-Instruct".to_string(),
146 "qwen3:0.6b" => "Qwen/Qwen3-0.6B".to_string(),
147 "qwen3:1.7b" => "Qwen/Qwen3-1.7B".to_string(),
148 "qwen3:4b" => "Qwen/Qwen3-4B".to_string(),
149 "qwen3:14b" => "Qwen/Qwen3-14B".to_string(),
150 "qwen3:32b" => "Qwen/Qwen3-32B".to_string(),
151 "qwen3.5:4b" => "Qwen/Qwen3.5-4B".to_string(),
152 "qwen3-coder:30b" | "qwen3-coder:30b-a3b" => {
153 "Qwen/Qwen3-Coder-30B-A3B-Instruct".to_string()
154 }
155 "qwen3-coder:30b-gptq" => "jart25/Qwen3-Coder-30B-A3B-Instruct-Int4-gptq".to_string(),
156 "qwen3:14b-gptq" => "JunHowie/Qwen3-14B-GPTQ-Int4".to_string(),
157 "qwen3:32b-gptq" => "JunHowie/Qwen3-32B-GPTQ-Int4".to_string(),
158 "deepseek-r1:8b" | "r1:8b" => "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B".to_string(),
159 "deepseek-r1:14b" | "r1:14b" => "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B".to_string(),
160 "deepseek-r1:32b" | "r1:32b" => "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B".to_string(),
161 "deepseek-r1:32b-gptq" => "OPEA/DeepSeek-R1-Distill-Qwen-32B-int4-gptq-sym-inc".to_string(),
162 "qwen2.5-coder:32b" => "Qwen/Qwen2.5-Coder-32B-Instruct".to_string(),
163 "qwen2.5-coder:32b-gptq" => "Qwen/Qwen2.5-Coder-32B-Instruct-GPTQ-Int4".to_string(),
164 "qwen2.5-coder:14b" => "Qwen/Qwen2.5-Coder-14B-Instruct".to_string(),
165 "gemma3:1b" => "unsloth/gemma-3-1b-it".to_string(),
166 "gemma3:4b" => "unsloth/gemma-3-4b-it".to_string(),
167 "gemma3:27b" => "unsloth/gemma-3-27b-it".to_string(),
168 "gemma3:27b-gptq" => "circulus/gemma-3-27b-it-gptq".to_string(),
169 "mistral-small:24b" | "mistral-small:3.2" => {
170 "mistralai/Mistral-Small-3.2-24B-Instruct-2506".to_string()
171 }
172 "devstral:24b" | "devstral:2" => "mistralai/Devstral-Small-2-24B-Instruct-2512".to_string(),
173 "magistral:24b" => "mistralai/Magistral-Small-2509".to_string(),
174 "qwen2.5:3b-gptq" | "qwen2.5-3b-instruct-gptq-int4" => {
175 "Qwen/Qwen2.5-3B-Instruct-GPTQ-Int4".to_string()
176 }
177 "llama3.2:1b" => "meta-llama/Llama-3.2-1B-Instruct".to_string(),
178 "llama3.2:3b" => "meta-llama/Llama-3.2-3B-Instruct".to_string(),
179 "whisper-tiny" | "whisper:tiny" => "openai/whisper-tiny".to_string(),
180 "whisper-base" | "whisper:base" => "openai/whisper-base".to_string(),
181 "whisper-small" | "whisper:small" => "openai/whisper-small".to_string(),
182 "whisper-medium" | "whisper:medium" => "openai/whisper-medium".to_string(),
183 "whisper-large-v3" | "whisper:large-v3" => "openai/whisper-large-v3".to_string(),
184 "whisper-turbo" | "whisper:turbo" | "whisper-large-v3-turbo" => {
185 "openai/whisper-large-v3-turbo".to_string()
186 }
187 "qwen3-tts" | "tts" | "tts:0.6b" => "Qwen/Qwen3-TTS-12Hz-0.6B-Base".to_string(),
188 "tts:1.7b" | "qwen3-tts:1.7b" => "Qwen/Qwen3-TTS-12Hz-1.7B-Base".to_string(),
189 _ => name.to_string(),
190 }
191}
192
193struct GgufAliasEntry {
194 aliases: &'static [&'static str],
195 repo: &'static str,
196 filename: &'static str,
197 tokenizer_repo: Option<&'static str>,
198}
199
200const GGUF_ALIASES: &[GgufAliasEntry] = &[
201 GgufAliasEntry {
202 aliases: &["qwen3.5:4b-gguf", "qwen3.5:4b-q4_k_m"],
203 repo: "unsloth/Qwen3.5-4B-GGUF",
204 filename: "Qwen3.5-4B-Q4_K_M.gguf",
205 tokenizer_repo: Some("Qwen/Qwen3.5-4B"),
206 },
207 GgufAliasEntry {
208 aliases: &["qwen3.5:35b-a3b-gguf", "qwen3.5:35b-a3b-q4_k_s"],
209 repo: "unsloth/Qwen3.5-35B-A3B-GGUF",
210 filename: "Qwen3.5-35B-A3B-Q4_K_S.gguf",
211 tokenizer_repo: Some("Qwen/Qwen3.5-35B-A3B"),
212 },
213 GgufAliasEntry {
214 aliases: &["qwen3:8b-q4_k_m"],
215 repo: "Qwen/Qwen3-8B-GGUF",
216 filename: "Qwen3-8B-Q4_K_M.gguf",
217 tokenizer_repo: None,
218 },
219 GgufAliasEntry {
220 aliases: &["qwen3:4b-q4_k_m"],
221 repo: "Qwen/Qwen3-4B-GGUF",
222 filename: "Qwen3-4B-Q4_K_M.gguf",
223 tokenizer_repo: None,
224 },
225 GgufAliasEntry {
226 aliases: &["qwen3:1.7b-gguf", "qwen3:1.7b-q8_0"],
230 repo: "Qwen/Qwen3-1.7B-GGUF",
231 filename: "Qwen3-1.7B-Q8_0.gguf",
232 tokenizer_repo: None,
233 },
234 GgufAliasEntry {
235 aliases: &["qwen3:0.6b-gguf", "qwen3:0.6b-q8_0"],
236 repo: "Qwen/Qwen3-0.6B-GGUF",
237 filename: "Qwen3-0.6B-Q8_0.gguf",
238 tokenizer_repo: None,
239 },
240 GgufAliasEntry {
241 aliases: &["qwen3-moe:30b-a3b-q4_k_m", "qwen3:30b-a3b-q4_k_m"],
242 repo: "Qwen/Qwen3-30B-A3B-GGUF",
243 filename: "Qwen3-30B-A3B-Q4_K_M.gguf",
244 tokenizer_repo: None,
245 },
246 GgufAliasEntry {
247 aliases: &["gemma3:1b-q4_k_m"],
248 repo: "unsloth/gemma-3-1b-it-GGUF",
249 filename: "gemma-3-1b-it-Q4_K_M.gguf",
250 tokenizer_repo: Some("unsloth/gemma-3-1b-it"),
251 },
252 GgufAliasEntry {
253 aliases: &["gemma3:27b-q4_k_m"],
254 repo: "unsloth/gemma-3-27b-it-GGUF",
255 filename: "gemma-3-27b-it-Q4_K_M.gguf",
256 tokenizer_repo: Some("unsloth/gemma-3-27b-it"),
257 },
258 GgufAliasEntry {
259 aliases: &["qwen3:14b-q4_k_m"],
260 repo: "Qwen/Qwen3-14B-GGUF",
261 filename: "Qwen3-14B-Q4_K_M.gguf",
262 tokenizer_repo: None,
263 },
264 GgufAliasEntry {
265 aliases: &["qwen3:32b-q4_k_m"],
266 repo: "Qwen/Qwen3-32B-GGUF",
267 filename: "Qwen3-32B-Q4_K_M.gguf",
268 tokenizer_repo: None,
269 },
270 GgufAliasEntry {
271 aliases: &["qwen3-coder:30b-q4_k_m", "qwen3-coder:30b-a3b-q4_k_m"],
272 repo: "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
273 filename: "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
274 tokenizer_repo: None,
275 },
276 GgufAliasEntry {
277 aliases: &["deepseek-r1:8b-q4_k_m", "r1:8b-q4_k_m"],
278 repo: "unsloth/DeepSeek-R1-0528-Qwen3-8B-GGUF",
279 filename: "DeepSeek-R1-0528-Qwen3-8B-Q4_K_M.gguf",
280 tokenizer_repo: None,
281 },
282 GgufAliasEntry {
283 aliases: &["deepseek-r1:32b-q4_k_m", "r1:32b-q4_k_m"],
284 repo: "unsloth/DeepSeek-R1-Distill-Qwen-32B-GGUF",
285 filename: "DeepSeek-R1-Distill-Qwen-32B-Q4_K_M.gguf",
286 tokenizer_repo: None,
287 },
288 GgufAliasEntry {
289 aliases: &["qwen2.5-coder:32b-q4_k_m"],
290 repo: "bartowski/Qwen2.5-Coder-32B-Instruct-GGUF",
291 filename: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
292 tokenizer_repo: Some("Qwen/Qwen2.5-Coder-32B-Instruct"),
293 },
294 GgufAliasEntry {
295 aliases: &["mistral-small:24b-q4_k_m"],
296 repo: "bartowski/mistralai_Mistral-Small-3.2-24B-Instruct-2506-GGUF",
297 filename: "mistralai_Mistral-Small-3.2-24B-Instruct-2506-Q4_K_M.gguf",
298 tokenizer_repo: Some("unsloth/Mistral-Small-3.2-24B-Instruct-2506"),
299 },
300 GgufAliasEntry {
301 aliases: &["devstral:24b-q4_k_m"],
302 repo: "bartowski/mistralai_Devstral-Small-2-24B-Instruct-2512-GGUF",
303 filename: "mistralai_Devstral-Small-2-24B-Instruct-2512-Q4_K_M.gguf",
304 tokenizer_repo: Some("mistralai/Devstral-Small-2-24B-Instruct-2512"),
305 },
306 GgufAliasEntry {
307 aliases: &["magistral:24b-q4_k_m"],
308 repo: "bartowski/mistralai_Magistral-Small-2509-GGUF",
309 filename: "mistralai_Magistral-Small-2509-Q4_K_M.gguf",
310 tokenizer_repo: Some("unsloth/Magistral-Small-2509"),
311 },
312 GgufAliasEntry {
313 aliases: &["llama3.1:8b-q4_k_m"],
314 repo: "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
315 filename: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
316 tokenizer_repo: Some("unsloth/Meta-Llama-3.1-8B-Instruct"),
317 },
318 GgufAliasEntry {
319 aliases: &["llama3.2:3b-q4_k_m"],
320 repo: "bartowski/Llama-3.2-3B-Instruct-GGUF",
321 filename: "Llama-3.2-3B-Instruct-Q4_K_M.gguf",
322 tokenizer_repo: Some("unsloth/Llama-3.2-3B-Instruct"),
323 },
324 GgufAliasEntry {
325 aliases: &["llama3.2:1b-q4_k_m"],
326 repo: "bartowski/Llama-3.2-1B-Instruct-GGUF",
327 filename: "Llama-3.2-1B-Instruct-Q4_K_M.gguf",
328 tokenizer_repo: Some("unsloth/Llama-3.2-1B-Instruct"),
329 },
330];
331
332pub fn resolve_gguf_alias(name: &str) -> Option<(String, String)> {
334 let name = name.to_lowercase();
335 GGUF_ALIASES
336 .iter()
337 .find(|entry| entry.aliases.contains(&name.as_str()))
338 .map(|entry| (entry.repo.to_string(), entry.filename.to_string()))
339}
340
341pub fn tokenizer_sibling_repo(gguf_repo: &str) -> Option<String> {
343 if let Some(entry) = GGUF_ALIASES.iter().find(|entry| entry.repo == gguf_repo) {
344 if let Some(repo) = entry.tokenizer_repo {
345 return Some(repo.to_string());
346 }
347 }
348 gguf_repo.strip_suffix("-GGUF").map(str::to_string)
349}
350
351pub fn apply_chat_profile_env(snapshot_path: &Path) {
383 let entries = chat_profile_runtime_entries(
384 snapshot_path,
385 &RuntimeConfigSnapshot::capture_current(),
386 RuntimeConfigSource::Default,
387 );
388 crate::runtime_env::materialize_runtime_env_defaults(&entries);
389}
390
391pub fn chat_profile_runtime_entries(
392 snapshot_path: &Path,
393 current: &RuntimeConfigSnapshot,
394 source: RuntimeConfigSource,
395) -> Vec<RuntimeConfigEntry> {
396 let is_gguf = snapshot_path.is_file()
397 && snapshot_path
398 .extension()
399 .map(|e| e.eq_ignore_ascii_case("gguf"))
400 .unwrap_or(false);
401 let is_moe = detect_moe_arch(snapshot_path);
402 let model_family = detect_model_family(snapshot_path);
403 chat_profile_runtime_entries_for_arch(is_gguf, is_moe, model_family.as_deref(), current, source)
404}
405
406fn chat_profile_runtime_entries_for_arch(
407 is_gguf: bool,
408 is_moe: bool,
409 model_family: Option<&str>,
410 current: &RuntimeConfigSnapshot,
411 source: RuntimeConfigSource,
412) -> Vec<RuntimeConfigEntry> {
413 let mut entries = Vec::new();
414
415 push_missing_entry(
416 &mut entries,
417 current,
418 "FERRUM_KV_CAPACITY",
419 if is_moe { "4096" } else { "8192" },
420 source,
421 );
422 let need_paged = !is_gguf
425 && (is_moe
426 || model_family.is_some_and(|family| {
427 family.eq_ignore_ascii_case("qwen3") || family.eq_ignore_ascii_case("qwen3_5")
428 }));
429 push_paged_kv_compat_entries(
430 &mut entries,
431 current,
432 if need_paged { "1" } else { "0" },
433 source,
434 );
435 if !is_gguf || is_moe {
440 for (k, v) in [
441 ("FERRUM_PAGED_MAX_SEQS", if is_moe { "1" } else { "2" }),
442 ("FERRUM_MAX_BATCH", "1"),
443 ] {
444 push_missing_entry(&mut entries, current, k, v, source);
445 }
446 }
447 if is_moe {
448 for (k, v) in [
449 ("FERRUM_MOE_BATCHED", "0"),
450 ("FERRUM_MOE_BATCHED_DECODE", "0"),
451 ("FERRUM_MOE_BATCH_THRESHOLD", "2"),
452 ] {
453 push_missing_entry(&mut entries, current, k, v, source);
454 }
455 }
456 entries
457}
458
459fn push_missing_entry(
460 entries: &mut Vec<RuntimeConfigEntry>,
461 current: &RuntimeConfigSnapshot,
462 key: &str,
463 value: &str,
464 source: RuntimeConfigSource,
465) {
466 if snapshot_value(current, key).is_none() {
467 entries.push(RuntimeConfigEntry::new(key, value, source));
468 }
469}
470
471fn push_paged_kv_compat_entries(
472 entries: &mut Vec<RuntimeConfigEntry>,
473 current: &RuntimeConfigSnapshot,
474 value: &str,
475 source: RuntimeConfigSource,
476) {
477 let effective_value = snapshot_value(current, "FERRUM_PAGED_KV")
478 .or_else(|| snapshot_value(current, "FERRUM_METAL_PAGED_KV"))
479 .unwrap_or(value);
480 push_missing_entry(entries, current, "FERRUM_PAGED_KV", effective_value, source);
481 push_missing_entry(
482 entries,
483 current,
484 "FERRUM_METAL_PAGED_KV",
485 effective_value,
486 source,
487 );
488}
489
490fn snapshot_value<'a>(snapshot: &'a RuntimeConfigSnapshot, key: &str) -> Option<&'a str> {
491 snapshot
492 .entries
493 .iter()
494 .find(|entry| entry.key == key)
495 .map(|entry| entry.effective_value.as_str())
496}
497
498pub fn detect_moe_arch(path: &Path) -> bool {
503 use ferrum_quantization::gguf::GgufFile;
504
505 if path.is_file()
506 && path
507 .extension()
508 .map(|e| e.eq_ignore_ascii_case("gguf"))
509 .unwrap_or(false)
510 {
511 return GgufFile::open(path)
512 .ok()
513 .and_then(|g| g.architecture().ok().map(|s| s.to_string()))
514 .map(|a| a.to_lowercase().contains("moe"))
515 .unwrap_or(false);
516 }
517
518 let config_path = path.join("config.json");
519 let Ok(contents) = std::fs::read_to_string(&config_path) else {
520 return false;
521 };
522 let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
523 return false;
524 };
525 if let Some(archs) = json.get("architectures").and_then(|v| v.as_array()) {
526 if archs
527 .iter()
528 .any(|a| a.as_str().is_some_and(|s| s.to_lowercase().contains("moe")))
529 {
530 return true;
531 }
532 }
533 json.get("model_type")
534 .and_then(|v| v.as_str())
535 .is_some_and(|mt| mt.to_lowercase().contains("moe"))
536}
537
538pub fn detect_model_family(path: &Path) -> Option<String> {
539 use ferrum_quantization::gguf::GgufFile;
540
541 if path.is_file()
542 && path
543 .extension()
544 .map(|e| e.eq_ignore_ascii_case("gguf"))
545 .unwrap_or(false)
546 {
547 return GgufFile::open(path)
548 .ok()
549 .and_then(|g| g.architecture().ok().map(|s| normalize_model_family(s)));
550 }
551
552 let config_path = path.join("config.json");
553 let contents = std::fs::read_to_string(&config_path).ok()?;
554 let json = serde_json::from_str::<serde_json::Value>(&contents).ok()?;
555 if let Some(model_type) = json.get("model_type").and_then(|v| v.as_str()) {
556 return Some(normalize_model_family(model_type));
557 }
558 json.get("architectures")
559 .and_then(|v| v.as_array())
560 .and_then(|archs| archs.iter().find_map(|arch| arch.as_str()))
561 .map(normalize_model_family)
562}
563
564fn normalize_model_family(raw: &str) -> String {
565 let lower = raw.to_ascii_lowercase();
566 if lower.contains("qwen3_5_moe")
567 || lower.contains("qwen3_5moe")
568 || lower.contains("qwen35_moe")
569 || lower.contains("qwen35moe")
570 {
571 "qwen3_5_moe".to_string()
572 } else if lower.contains("qwen3_5") || lower.contains("qwen35") {
573 "qwen3_5".to_string()
574 } else if lower.contains("qwen3_moe")
575 || lower.contains("qwen3moe")
576 || lower.contains("qwen3_mo")
577 {
578 "qwen3_moe".to_string()
579 } else if lower.contains("qwen3") {
580 "qwen3".to_string()
581 } else if lower.contains("qwen2") || lower == "qwen" {
582 "qwen2".to_string()
583 } else if lower.contains("mistral") {
584 "mistral".to_string()
585 } else if lower.contains("llama") || lower.contains("tinyllama") {
586 "llama".to_string()
587 } else {
588 lower
589 }
590}
591
592pub fn metal_gguf_moe_correctness_entries(
600 snapshot_path: &Path,
601 device: &ferrum_types::Device,
602 current: &RuntimeConfigSnapshot,
603 source: RuntimeConfigSource,
604) -> Vec<RuntimeConfigEntry> {
605 let is_gguf = snapshot_path.is_file()
606 && snapshot_path
607 .extension()
608 .map(|e| e.eq_ignore_ascii_case("gguf"))
609 .unwrap_or(false);
610 if !is_gguf || !device_is_metal(device) || !detect_moe_arch(snapshot_path) {
611 return Vec::new();
612 }
613
614 let mut entries = Vec::new();
615 push_missing_entry(&mut entries, current, "FERRUM_MOE_HOST_TOPK", "1", source);
616 entries
617}
618
619pub fn serve_profile_runtime_entries(
631 snapshot_path: &Path,
632 device: &ferrum_types::Device,
633 vnext_plan_owns_context_capacity: bool,
634 current: &RuntimeConfigSnapshot,
635 source: RuntimeConfigSource,
636) -> Vec<RuntimeConfigEntry> {
637 let is_gguf = snapshot_path.is_file()
638 && snapshot_path
639 .extension()
640 .map(|e| e.eq_ignore_ascii_case("gguf"))
641 .unwrap_or(false);
642 serve_profile_runtime_entries_for_arch(
643 is_gguf,
644 detect_moe_arch(snapshot_path),
645 device_is_metal(device),
646 vnext_plan_owns_context_capacity,
647 current,
648 source,
649 )
650}
651
652fn device_is_metal(device: &ferrum_types::Device) -> bool {
653 #[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "metal"))]
654 {
655 matches!(device, ferrum_types::Device::Metal)
656 }
657 #[cfg(not(all(any(target_os = "macos", target_os = "ios"), feature = "metal")))]
658 {
659 let _ = device;
660 false
661 }
662}
663
664pub fn serve_profile_runtime_entries_for_arch(
665 is_gguf: bool,
666 is_moe: bool,
667 is_metal: bool,
668 vnext_plan_owns_context_capacity: bool,
669 current: &RuntimeConfigSnapshot,
670 source: RuntimeConfigSource,
671) -> Vec<RuntimeConfigEntry> {
672 if !is_gguf {
673 return Vec::new();
674 }
675
676 let mut entries = Vec::new();
677 if !vnext_plan_owns_context_capacity {
678 let kv_capacity = if is_moe && is_metal {
679 "1024"
684 } else if is_moe {
685 "2048"
686 } else {
687 "512"
688 };
689 push_missing_entry(
690 &mut entries,
691 current,
692 "FERRUM_KV_CAPACITY",
693 kv_capacity,
694 source,
695 );
696 }
697 push_paged_kv_compat_entries(&mut entries, current, "1", source);
698 for (k, v) in [
699 (
700 "FERRUM_PAGED_MAX_SEQS",
701 if is_moe {
702 "16"
703 } else if is_metal {
704 "16"
705 } else {
706 "32"
707 },
708 ),
709 ("FERRUM_MAX_BATCH", "16"),
710 ] {
711 push_missing_entry(&mut entries, current, k, v, source);
712 }
713 if is_moe {
714 for (k, v) in [
715 ("FERRUM_MAX_BATCHED_TOKENS", "2048"),
716 ("FERRUM_MOE_BATCHED", "1"),
717 ("FERRUM_MOE_BATCHED_DECODE", "1"),
718 ("FERRUM_MOE_BATCH_THRESHOLD", "2"),
719 ] {
720 push_missing_entry(&mut entries, current, k, v, source);
721 }
722 }
723 entries
724}
725
726pub fn load_model_chat_template(snapshot_path: &Path) -> Option<ModelChatTemplate> {
734 let is_gguf = snapshot_path.is_file()
735 && snapshot_path
736 .extension()
737 .map(|e| e.eq_ignore_ascii_case("gguf"))
738 .unwrap_or(false);
739 if is_gguf {
740 let gguf = ferrum_quantization::gguf::GgufFile::open(snapshot_path).ok()?;
741 let template = gguf.metadata_string("tokenizer.chat_template").ok()?;
742 return Some(ModelChatTemplate::new(
743 template.to_string(),
744 format!("{}:tokenizer.chat_template", snapshot_path.display()),
745 ));
746 }
747
748 if !snapshot_path.is_dir() {
749 return None;
750 }
751
752 let jinja_path = snapshot_path.join("chat_template.jinja");
753 if let Ok(template) = std::fs::read_to_string(&jinja_path) {
754 if !template.trim().is_empty() {
755 return Some(ModelChatTemplate::new(
756 template,
757 jinja_path.display().to_string(),
758 ));
759 }
760 }
761
762 let json_path = snapshot_path.join("chat_template.json");
763 if let Some(template) = read_template_json(&json_path) {
764 return Some(template);
765 }
766
767 let tokenizer_config_path = snapshot_path.join("tokenizer_config.json");
768 read_tokenizer_config_template(&tokenizer_config_path)
769}
770
771pub fn load_product_chat_template(
774 sources: &ProductionModelSourceBundle,
775) -> Option<ModelChatTemplate> {
776 if let Some(bytes) = sources.chat_template_jinja() {
777 let template = std::str::from_utf8(bytes).ok()?;
778 if !template.trim().is_empty() {
779 return Some(ModelChatTemplate::new(
780 template.to_owned(),
781 sources
782 .tokenizer_root()
783 .join("chat_template.jinja")
784 .display()
785 .to_string(),
786 ));
787 }
788 }
789 if let Some(bytes) = sources.chat_template_json() {
790 let origin = sources
791 .tokenizer_root()
792 .join("chat_template.json")
793 .display()
794 .to_string();
795 if let Some(template) = template_json_bytes(bytes, origin) {
796 return Some(template);
797 }
798 }
799 sources.tokenizer_config_json().and_then(|bytes| {
800 tokenizer_config_template_bytes(
801 bytes,
802 sources
803 .tokenizer_root()
804 .join("tokenizer_config.json")
805 .display()
806 .to_string(),
807 )
808 })
809}
810
811fn load_product_chat_template_source(
812 sources: &ProductionModelSourceBundle,
813 source_file: &str,
814) -> Option<ModelChatTemplate> {
815 let origin = sources.tokenizer_root().join(source_file);
816 match source_file {
817 "tokenizer_config.json" => sources
818 .tokenizer_config_json()
819 .and_then(|bytes| tokenizer_config_template_bytes(bytes, origin.display().to_string())),
820 "chat_template.json" => sources
821 .chat_template_json()
822 .and_then(|bytes| template_json_bytes(bytes, origin.display().to_string())),
823 "chat_template.jinja" => sources.chat_template_jinja().and_then(|bytes| {
824 let template = std::str::from_utf8(bytes).ok()?;
825 (!template.trim().is_empty())
826 .then(|| ModelChatTemplate::new(template.to_owned(), origin.display().to_string()))
827 }),
828 _ => None,
829 }
830}
831
832pub fn load_prepared_product_chat_template(
833 prepared: &ferrum_models::vnext::PreparedProductionModel,
834) -> Result<ModelChatTemplate> {
835 let metadata = &prepared.family().metadata().template;
836 let selected = load_product_chat_template_source(prepared.sources(), &metadata.source_file)
837 .ok_or_else(|| {
838 FerrumError::model(format!(
839 "typed product chat template source is unavailable: {}",
840 metadata.source_file
841 ))
842 })?;
843 if selected.template != metadata.template {
844 return Err(FerrumError::model(
845 "typed product chat template bytes differ from the prepared family",
846 ));
847 }
848 Ok(selected)
849}
850
851fn read_template_json(path: &Path) -> Option<ModelChatTemplate> {
852 let bytes = std::fs::read(path).ok()?;
853 template_json_bytes(&bytes, path.display().to_string())
854}
855
856fn template_json_bytes(bytes: &[u8], origin: String) -> Option<ModelChatTemplate> {
857 if bytes.iter().all(u8::is_ascii_whitespace) {
858 return None;
859 }
860 match serde_json::from_slice::<serde_json::Value>(bytes).ok() {
861 Some(serde_json::Value::String(template)) => Some(ModelChatTemplate::new(template, origin)),
862 Some(value) => template_value(&value).map(|template| {
863 let mut t = ModelChatTemplate::new(template, origin);
864 t.bos_token = token_value(&value, "bos_token");
865 t.eos_token = token_value(&value, "eos_token");
866 t
867 }),
868 None => std::str::from_utf8(bytes)
869 .ok()
870 .map(|text| ModelChatTemplate::new(text.to_owned(), origin)),
871 }
872}
873
874fn read_tokenizer_config_template(path: &Path) -> Option<ModelChatTemplate> {
875 let bytes = std::fs::read(path).ok()?;
876 tokenizer_config_template_bytes(&bytes, path.display().to_string())
877}
878
879fn tokenizer_config_template_bytes(bytes: &[u8], origin: String) -> Option<ModelChatTemplate> {
880 let value = serde_json::from_slice::<serde_json::Value>(bytes).ok()?;
881 let template = template_value(&value)?;
882 let mut t = ModelChatTemplate::new(template, origin);
883 t.bos_token = token_value(&value, "bos_token");
884 t.eos_token = token_value(&value, "eos_token");
885 Some(t)
886}
887
888fn template_value(value: &serde_json::Value) -> Option<String> {
889 match value.get("chat_template")? {
890 serde_json::Value::String(s) => Some(s.clone()),
891 serde_json::Value::Array(items) => items
892 .iter()
893 .find(|item| item.get("name").and_then(|v| v.as_str()) == Some("default"))
894 .or_else(|| items.first())
895 .and_then(|item| item.get("template").and_then(|v| v.as_str()))
896 .map(ToString::to_string),
897 serde_json::Value::Object(obj) => obj
898 .get("template")
899 .and_then(|v| v.as_str())
900 .map(ToString::to_string),
901 _ => None,
902 }
903}
904
905fn token_value(value: &serde_json::Value, key: &str) -> Option<String> {
906 match value.get(key)? {
907 serde_json::Value::String(s) => Some(s.clone()),
908 serde_json::Value::Object(obj) => obj
909 .get("content")
910 .and_then(|v| v.as_str())
911 .map(ToString::to_string),
912 _ => None,
913 }
914}
915
916pub fn find_cached_model(cache_dir: &Path, model_id: &str) -> Option<ResolvedModelSource> {
919 let repo_dir = cache_dir
920 .join("hub")
921 .join(format!("models--{}", model_id.replace('/', "--")));
922 let snapshots_dir = repo_dir.join("snapshots");
923
924 let ref_main = repo_dir.join("refs").join("main");
926 if let Ok(rev) = std::fs::read_to_string(&ref_main) {
927 let rev = rev.trim();
928 if !rev.is_empty() {
929 let snapshot = snapshots_dir.join(rev);
930 if snapshot.exists() {
931 let format = detect_format(&snapshot);
932 if format != ModelFormat::Unknown {
933 return Some(ResolvedModelSource {
934 original: model_id.to_string(),
935 local_path: snapshot,
936 format,
937 from_cache: true,
938 });
939 }
940 }
941 }
942 }
943
944 if let Ok(entries) = std::fs::read_dir(&snapshots_dir) {
946 for entry in entries.flatten() {
947 let path = entry.path();
948 if path.is_dir() {
949 let format = detect_format(&path);
950 if format != ModelFormat::Unknown {
951 return Some(ResolvedModelSource {
952 original: model_id.to_string(),
953 local_path: path,
954 format,
955 from_cache: true,
956 });
957 }
958 }
959 }
960 }
961
962 None
963}
964
965pub fn find_cached_gguf(cache_dir: &Path, repo: &str, filename: &str) -> Option<PathBuf> {
967 let repo_dir = cache_dir
968 .join("hub")
969 .join(format!("models--{}", repo.replace('/', "--")));
970 let snapshots_dir = repo_dir.join("snapshots");
971
972 let ref_main = repo_dir.join("refs").join("main");
973 if let Ok(revision) = std::fs::read_to_string(&ref_main) {
974 let revision = revision.trim();
975 if !revision.is_empty() {
976 let candidate = snapshots_dir.join(revision).join(filename);
977 if candidate.is_file() {
978 return Some(candidate);
979 }
980 }
981 }
982
983 std::fs::read_dir(&snapshots_dir)
984 .ok()?
985 .flatten()
986 .map(|entry| entry.path().join(filename))
987 .find(|candidate| candidate.is_file())
988}
989
990const PRODUCT_SOURCE_FILES: [&str; 7] = [
991 "config.json",
992 "tokenizer.json",
993 "tokenizer_config.json",
994 "special_tokens_map.json",
995 "chat_template.json",
996 "chat_template.jinja",
997 "generation_config.json",
998];
999
1000fn is_complete_product_metadata_snapshot(path: &Path) -> bool {
1001 path.is_dir() && path.join("config.json").is_file() && path.join("tokenizer.json").is_file()
1002}
1003
1004fn find_cached_product_metadata(cache_dir: &Path, repo: &str) -> Option<PathBuf> {
1007 let repo_dir = cache_dir
1008 .join("hub")
1009 .join(format!("models--{}", repo.replace('/', "--")));
1010 let snapshots_dir = repo_dir.join("snapshots");
1011
1012 if let Ok(revision) = std::fs::read_to_string(repo_dir.join("refs/main")) {
1013 let revision = revision.trim();
1014 if !revision.is_empty() {
1015 let candidate = snapshots_dir.join(revision);
1016 if is_complete_product_metadata_snapshot(&candidate) {
1017 return Some(candidate);
1018 }
1019 }
1020 }
1021
1022 std::fs::read_dir(&snapshots_dir)
1023 .ok()?
1024 .flatten()
1025 .map(|entry| entry.path())
1026 .find(|candidate| is_complete_product_metadata_snapshot(candidate))
1027}
1028
1029fn repository_source(repo: impl Into<String>) -> OriginalModelSource {
1030 OriginalModelSource {
1031 kind: ModelSourceKind::Repository,
1032 location: repo.into(),
1033 requested_revision: None,
1034 }
1035}
1036
1037fn original_product_source(
1038 source: &ModelSource,
1039 resolved_path: &Path,
1040) -> Result<OriginalModelSource> {
1041 match source {
1042 ModelSource::Local(location) => Ok(OriginalModelSource {
1043 kind: if resolved_path.is_file() {
1044 ModelSourceKind::LocalFile
1045 } else {
1046 ModelSourceKind::LocalDirectory
1047 },
1048 location: location.clone(),
1049 requested_revision: None,
1050 }),
1051 ModelSource::HuggingFace {
1052 repo_id, revision, ..
1053 } => Ok(OriginalModelSource {
1054 kind: ModelSourceKind::Repository,
1055 location: repo_id.clone(),
1056 requested_revision: revision.clone(),
1057 }),
1058 ModelSource::Url { .. } | ModelSource::S3 { .. } => Err(FerrumError::unsupported(
1059 "typed product source bundles do not yet resolve URL or S3 sources",
1060 )),
1061 }
1062}
1063
1064fn open_colocated_product_sources(
1065 source: &ResolvedModelSource,
1066 original_source: &ModelSource,
1067) -> Result<Option<Arc<ProductionModelSourceBundle>>> {
1068 let (metadata_root, weights, original_sources) = match source.format {
1069 ModelFormat::SafeTensors if is_complete_product_metadata_snapshot(&source.local_path) => {
1070 let original = original_product_source(original_source, &source.local_path)?;
1071 (
1072 source.local_path.as_path(),
1073 ProductionWeightArtifact::safetensors_directory(&source.local_path),
1074 OriginalModelSources {
1075 semantic: original.clone(),
1076 tokenizer: original.clone(),
1077 weights: original,
1078 },
1079 )
1080 }
1081 ModelFormat::GGUF => {
1082 let metadata_root = source
1083 .local_path
1084 .parent()
1085 .filter(|parent| !parent.as_os_str().is_empty())
1086 .unwrap_or_else(|| Path::new("."));
1087 if !is_complete_product_metadata_snapshot(metadata_root) {
1088 return Ok(None);
1089 }
1090 let metadata_original = OriginalModelSource {
1091 kind: ModelSourceKind::LocalDirectory,
1092 location: metadata_root.display().to_string(),
1093 requested_revision: None,
1094 };
1095 (
1096 metadata_root,
1097 ProductionWeightArtifact::gguf_file(&source.local_path),
1098 OriginalModelSources {
1099 semantic: metadata_original.clone(),
1100 tokenizer: metadata_original,
1101 weights: original_product_source(original_source, &source.local_path)?,
1102 },
1103 )
1104 }
1105 _ => return Ok(None),
1106 };
1107 open_registered_product_sources(metadata_root, metadata_root, weights, original_sources)
1108 .map(Arc::new)
1109 .map(Some)
1110}
1111
1112fn direct_gguf_requires_typed_product_sources(path: &Path) -> bool {
1113 ferrum_quantization::gguf::GgufFile::open(path)
1114 .ok()
1115 .and_then(|gguf| gguf.architecture().ok().map(str::to_owned))
1116 .is_some_and(|architecture| {
1117 ferrum_models::vnext::gguf_architecture_requires_typed_product_sources(&architecture)
1118 })
1119}
1120
1121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1125pub enum DownloadPolicy {
1126 AutoDownload,
1128 NoDownload,
1130}
1131
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1133enum ProductSourceComposition {
1134 ResolveColocated,
1135 DeferUntilExplicitSemantic,
1136}
1137
1138pub struct Resolved {
1141 source: ResolvedModelSource,
1142 requested_model: String,
1143 public_model_id: String,
1144 original_source: ModelSource,
1148 model_sources: Option<Arc<ProductionModelSourceBundle>>,
1152 autosized: bool,
1155}
1156
1157pub struct ProductEngineInput {
1161 pub source: ResolvedModelSource,
1162 pub requested_model: String,
1163 pub public_model_id: String,
1164 pub engine_config: EngineConfig,
1165 pub model_sources: Option<Arc<ProductionModelSourceBundle>>,
1166 pub autosized: bool,
1167}
1168
1169pub fn prepare_registered_product_model(
1173 sources: &Arc<ProductionModelSourceBundle>,
1174) -> Result<Option<Arc<ferrum_models::vnext::PreparedProductionModel>>> {
1175 match ferrum_models::vnext::resolve_registered_model_from_sources(sources)? {
1176 ferrum_models::vnext::ProductionModelRegistration::Registered(registration) => registration
1177 .prepare_from_sources(Arc::clone(sources))
1178 .map(Arc::new)
1179 .map(Some),
1180 ferrum_models::vnext::ProductionModelRegistration::LegacyRegistered { .. } => Ok(None),
1181 }
1182}
1183
1184pub fn prepared_product_source_identity(
1185 prepared: &ferrum_models::vnext::PreparedProductionModel,
1186 requested_model: &str,
1187 resolved_model: &str,
1188 selected_template: Option<&ModelChatTemplate>,
1189) -> Result<ProductModelSourceIdentity> {
1190 let identity = prepared.product_source_identity(requested_model, resolved_model)?;
1191 let selected_template = selected_template.ok_or_else(|| {
1192 FerrumError::model("typed product model has no selected runtime chat template")
1193 })?;
1194 let selected_sha256 = format!(
1195 "{:x}",
1196 Sha256::digest(selected_template.template.as_bytes())
1197 );
1198 if identity.template.content_sha256.as_deref() != Some(selected_sha256.as_str()) {
1199 return Err(FerrumError::model(
1200 "selected runtime chat template differs from the prepared typed family",
1201 ));
1202 }
1203 let selected_file = Path::new(&selected_template.source)
1204 .file_name()
1205 .and_then(|value| value.to_str());
1206 if selected_file != Some(identity.template.source_file.as_str()) {
1207 return Err(FerrumError::model(format!(
1208 "selected runtime chat template source differs from typed identity: {}",
1209 selected_template.source
1210 )));
1211 }
1212 Ok(identity)
1213}
1214
1215impl Resolved {
1216 pub fn local_path(&self) -> &Path {
1217 &self.source.local_path
1218 }
1219
1220 pub fn into_product_engine_input(self) -> ProductEngineInput {
1221 let mut engine_config = EngineConfig::default();
1222 engine_config.model.model_id = ModelId::new(self.public_model_id.clone());
1223 engine_config.model.source = Some(self.original_source);
1224 ProductEngineInput {
1225 source: self.source,
1226 requested_model: self.requested_model,
1227 public_model_id: self.public_model_id,
1228 engine_config,
1229 model_sources: self.model_sources,
1230 autosized: self.autosized,
1231 }
1232 }
1233}
1234
1235pub async fn resolve_model_source(
1251 model: &str,
1252 cache_dir: &Path,
1253 download: DownloadPolicy,
1254 autosize: Option<(AutoSizeProfile, f32)>,
1255) -> Result<Resolved> {
1256 resolve_model_source_internal(
1257 model,
1258 cache_dir,
1259 download,
1260 autosize,
1261 ProductSourceComposition::ResolveColocated,
1262 )
1263 .await
1264}
1265
1266async fn resolve_model_source_internal(
1267 model: &str,
1268 cache_dir: &Path,
1269 download: DownloadPolicy,
1270 autosize: Option<(AutoSizeProfile, f32)>,
1271 source_composition: ProductSourceComposition,
1272) -> Result<Resolved> {
1273 let defer_colocated_product_sources =
1274 source_composition == ProductSourceComposition::DeferUntilExplicitSemantic;
1275 if let Some((repo, filename)) = resolve_gguf_alias(model) {
1278 let token = (download == DownloadPolicy::AutoDownload)
1279 .then(|| {
1280 std::env::var("HF_TOKEN")
1281 .or_else(|_| std::env::var("HUGGING_FACE_HUB_TOKEN"))
1282 .ok()
1283 })
1284 .flatten();
1285 let (local_path, weights_from_cache) = match find_cached_gguf(cache_dir, &repo, &filename) {
1286 Some(path) => (path, true),
1287 None if download == DownloadPolicy::AutoDownload => {
1288 let downloader =
1289 ferrum_models::HfDownloader::new(cache_dir.to_path_buf(), token.clone())?;
1290 (
1291 downloader.download_gguf(&repo, None, &filename).await?,
1292 false,
1293 )
1294 }
1295 None => {
1296 return Err(FerrumError::model(format!(
1297 "GGUF alias '{model}' is not cached and DownloadPolicy::NoDownload is set"
1298 )))
1299 }
1300 };
1301 let (model_sources, metadata_from_cache) = if defer_colocated_product_sources {
1302 (None, true)
1303 } else {
1304 let metadata_repo = tokenizer_sibling_repo(&repo).ok_or_else(|| {
1305 FerrumError::model(format!(
1306 "GGUF repository '{repo}' has no semantic/tokenizer source"
1307 ))
1308 })?;
1309 let (metadata_root, metadata_from_cache) =
1310 match find_cached_product_metadata(cache_dir, &metadata_repo) {
1311 Some(path) => (path, true),
1312 None if download == DownloadPolicy::AutoDownload => {
1313 let downloader =
1314 ferrum_models::HfDownloader::new(cache_dir.to_path_buf(), token)?;
1315 let path = downloader
1316 .download_sidecar_files(&metadata_repo, None, &PRODUCT_SOURCE_FILES)
1317 .await?;
1318 if !is_complete_product_metadata_snapshot(&path) {
1319 return Err(FerrumError::model(format!(
1320 "semantic/tokenizer source '{metadata_repo}' did not provide config.json and tokenizer.json"
1321 )));
1322 }
1323 (path, false)
1324 }
1325 None => {
1326 return Err(FerrumError::model(format!(
1327 "semantic/tokenizer source '{metadata_repo}' for GGUF alias '{model}' is not cached and DownloadPolicy::NoDownload is set"
1328 )))
1329 }
1330 };
1331 let metadata_original = repository_source(metadata_repo);
1332 let sources = Arc::new(open_registered_product_sources(
1333 &metadata_root,
1334 &metadata_root,
1335 ProductionWeightArtifact::gguf_file(&local_path),
1336 OriginalModelSources {
1337 semantic: metadata_original.clone(),
1338 tokenizer: metadata_original,
1339 weights: repository_source(&repo),
1340 },
1341 )?);
1342 (Some(sources), metadata_from_cache)
1343 };
1344 return Ok(finalize_resolution(
1345 ResolvedModelSource {
1346 original: model.to_string(),
1347 local_path,
1348 format: ModelFormat::GGUF,
1349 from_cache: weights_from_cache && metadata_from_cache,
1350 },
1351 ModelSource::HuggingFace {
1352 repo_id: repo,
1353 revision: None,
1354 cache_dir: Some(cache_dir.display().to_string()),
1355 },
1356 model_sources,
1357 autosize,
1358 ));
1359 }
1360
1361 if looks_like_gguf_path(model) {
1363 let local_path = PathBuf::from(model);
1364 let source = ResolvedModelSource {
1365 original: model.to_string(),
1366 local_path,
1367 format: ModelFormat::GGUF,
1368 from_cache: false,
1369 };
1370 let original_source = ModelSource::Local(model.to_owned());
1371 let model_sources = if defer_colocated_product_sources {
1372 None
1373 } else {
1374 open_colocated_product_sources(&source, &original_source)?
1375 };
1376 if !defer_colocated_product_sources
1377 && model_sources.is_none()
1378 && direct_gguf_requires_typed_product_sources(&source.local_path)
1379 {
1380 return Err(FerrumError::unsupported(format!(
1381 "GGUF architecture in '{}' has migrated to the typed vNext product runtime; use a curated GGUF alias or place config.json and tokenizer.json beside the file",
1382 source.local_path.display()
1383 )));
1384 }
1385 return Ok(finalize_resolution(
1386 source,
1387 original_source,
1388 model_sources,
1389 autosize,
1390 ));
1391 }
1392
1393 let direct = PathBuf::from(model);
1395 if direct.is_dir() {
1396 let format = detect_format(&direct);
1397 if format != ModelFormat::Unknown {
1398 let source = ResolvedModelSource {
1399 original: model.to_string(),
1400 local_path: direct,
1401 format,
1402 from_cache: false,
1403 };
1404 let original_source = ModelSource::Local(model.to_owned());
1405 let model_sources = if defer_colocated_product_sources {
1406 None
1407 } else {
1408 open_colocated_product_sources(&source, &original_source)?
1409 };
1410 return Ok(finalize_resolution(
1411 source,
1412 original_source,
1413 model_sources,
1414 autosize,
1415 ));
1416 }
1417 }
1418
1419 let model_id = resolve_model_alias(model);
1421 if let Some(source) = find_cached_model(cache_dir, &model_id) {
1422 let original_source = ModelSource::HuggingFace {
1423 repo_id: model_id,
1424 revision: None,
1425 cache_dir: Some(cache_dir.display().to_string()),
1426 };
1427 let model_sources = if defer_colocated_product_sources {
1428 None
1429 } else {
1430 open_colocated_product_sources(&source, &original_source)?
1431 };
1432 return Ok(finalize_resolution(
1433 source,
1434 original_source,
1435 model_sources,
1436 autosize,
1437 ));
1438 }
1439
1440 if download != DownloadPolicy::AutoDownload {
1442 return Err(FerrumError::model(format!(
1443 "model '{}' not found locally and DownloadPolicy::NoDownload set",
1444 model_id
1445 )));
1446 }
1447
1448 let token = std::env::var("HF_TOKEN")
1449 .or_else(|_| std::env::var("HUGGING_FACE_HUB_TOKEN"))
1450 .ok();
1451 let downloader = ferrum_models::HfDownloader::new(cache_dir.to_path_buf(), token)?;
1452 let snapshot_path = downloader.download(&model_id, None).await?;
1453 let format = detect_format(&snapshot_path);
1454 if format == ModelFormat::Unknown {
1455 return Err(FerrumError::model(
1456 "downloaded model has unknown format (no safetensors / pytorch_model.bin)",
1457 ));
1458 }
1459 let source = ResolvedModelSource {
1460 original: model_id.clone(),
1461 local_path: snapshot_path,
1462 format,
1463 from_cache: false,
1464 };
1465 let original_source = ModelSource::HuggingFace {
1466 repo_id: model_id,
1467 revision: None,
1468 cache_dir: Some(cache_dir.display().to_string()),
1469 };
1470 let model_sources = if defer_colocated_product_sources {
1471 None
1472 } else {
1473 open_colocated_product_sources(&source, &original_source)?
1474 };
1475 Ok(finalize_resolution(
1476 source,
1477 original_source,
1478 model_sources,
1479 autosize,
1480 ))
1481}
1482
1483pub async fn resolve_model_source_with_product_sources(
1488 model: &str,
1489 cache_dir: &Path,
1490 download: DownloadPolicy,
1491 autosize: Option<(AutoSizeProfile, f32)>,
1492 source_args: &ProductSourceArgs,
1493) -> Result<Resolved> {
1494 let mut resolved = resolve_model_source_internal(
1495 model,
1496 cache_dir,
1497 download,
1498 autosize,
1499 if source_args.semantic_source.is_some() {
1500 ProductSourceComposition::DeferUntilExplicitSemantic
1501 } else {
1502 ProductSourceComposition::ResolveColocated
1503 },
1504 )
1505 .await?;
1506 resolved.requested_model = model.to_owned();
1507 apply_explicit_product_sources(resolved, source_args)
1508}
1509
1510fn apply_explicit_product_sources(
1511 mut resolved: Resolved,
1512 source_args: &ProductSourceArgs,
1513) -> Result<Resolved> {
1514 if source_args.semantic_source.is_none() && source_args.tokenizer_source.is_none() {
1515 return Ok(resolved);
1516 }
1517 let existing = resolved.model_sources.as_deref();
1518 let weight_root = match resolved.source.format {
1519 ModelFormat::GGUF => resolved
1520 .source
1521 .local_path
1522 .parent()
1523 .unwrap_or_else(|| Path::new(".")),
1524 _ => resolved.source.local_path.as_path(),
1525 };
1526 let semantic_root = source_args
1527 .semantic_source
1528 .as_deref()
1529 .or_else(|| existing.map(ProductionModelSourceBundle::semantic_root))
1530 .unwrap_or(weight_root);
1531 let tokenizer_root = source_args
1532 .tokenizer_source
1533 .as_deref()
1534 .or_else(|| source_args.semantic_source.as_ref().map(|_| semantic_root))
1535 .or_else(|| existing.map(ProductionModelSourceBundle::tokenizer_root))
1536 .unwrap_or(semantic_root);
1537 let weights = existing
1538 .map(|sources| sources.weights().clone())
1539 .unwrap_or_else(|| match resolved.source.format {
1540 ModelFormat::GGUF => ProductionWeightArtifact::gguf_file(&resolved.source.local_path),
1541 _ => ProductionWeightArtifact::safetensors_directory(&resolved.source.local_path),
1542 });
1543 let explicit_original = |path: &Path| OriginalModelSource {
1544 kind: if path.is_file() {
1545 ModelSourceKind::LocalFile
1546 } else {
1547 ModelSourceKind::LocalDirectory
1548 },
1549 location: path.display().to_string(),
1550 requested_revision: None,
1551 };
1552 let semantic_original = source_args
1553 .semantic_source
1554 .as_deref()
1555 .map(explicit_original)
1556 .or_else(|| existing.map(|sources| sources.original_sources().semantic.clone()))
1557 .unwrap_or_else(|| explicit_original(semantic_root));
1558 let tokenizer_original = source_args
1559 .tokenizer_source
1560 .as_deref()
1561 .map(explicit_original)
1562 .or_else(|| {
1563 source_args
1564 .semantic_source
1565 .as_ref()
1566 .map(|_| semantic_original.clone())
1567 })
1568 .or_else(|| existing.map(|sources| sources.original_sources().tokenizer.clone()))
1569 .unwrap_or_else(|| explicit_original(tokenizer_root));
1570 let weight_original = existing
1571 .map(|sources| sources.original_sources().weights.clone())
1572 .unwrap_or_else(|| {
1573 original_product_source(&resolved.original_source, &resolved.source.local_path)
1574 .unwrap_or_else(|_| explicit_original(weights.path()))
1575 });
1576 resolved.model_sources = Some(Arc::new(open_registered_product_sources(
1577 semantic_root,
1578 tokenizer_root,
1579 weights,
1580 OriginalModelSources {
1581 semantic: semantic_original,
1582 tokenizer: tokenizer_original,
1583 weights: weight_original,
1584 },
1585 )?));
1586 Ok(resolved)
1587}
1588
1589fn finalize_resolution(
1590 source: ResolvedModelSource,
1591 original_source: ModelSource,
1592 model_sources: Option<Arc<ProductionModelSourceBundle>>,
1593 autosize: Option<(AutoSizeProfile, f32)>,
1594) -> Resolved {
1595 let autosized = if let Some((profile, gpu_util)) = autosize {
1596 apply_auto_size_with_profile(&source.local_path, gpu_util, profile);
1597 if profile == AutoSizeProfile::Chat {
1598 apply_chat_profile_env(&source.local_path);
1599 }
1600 true
1601 } else {
1602 false
1603 };
1604 let requested_model = source.original.clone();
1605 let public_model_id = public_model_id(&source);
1606 Resolved {
1607 source,
1608 requested_model,
1609 public_model_id,
1610 original_source,
1611 model_sources,
1612 autosized,
1613 }
1614}
1615
1616#[cfg(test)]
1617mod tests {
1618 use super::*;
1619 use std::time::{SystemTime, UNIX_EPOCH};
1620
1621 fn temp_model_dir(name: &str, config_json: &str) -> PathBuf {
1622 let nonce = SystemTime::now()
1623 .duration_since(UNIX_EPOCH)
1624 .unwrap()
1625 .as_nanos();
1626 let dir = std::env::temp_dir().join(format!(
1627 "ferrum-source-resolver-{name}-{}-{nonce}",
1628 std::process::id()
1629 ));
1630 std::fs::create_dir_all(&dir).unwrap();
1631 std::fs::write(dir.join("config.json"), config_json).unwrap();
1632 std::fs::write(dir.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
1633 dir
1634 }
1635
1636 fn qwen35_semantic_config(moe: bool) -> String {
1637 let mut text = serde_json::json!({
1638 "model_type": if moe { "qwen3_5_moe_text" } else { "qwen3_5_text" },
1639 "hidden_size": 16,
1640 "num_hidden_layers": 2,
1641 "layer_types": ["linear_attention", "full_attention"],
1642 "linear_num_key_heads": 1,
1643 "linear_num_value_heads": 1,
1644 "linear_key_head_dim": 4,
1645 "linear_value_head_dim": 4,
1646 "linear_conv_kernel_dim": 2,
1647 "mamba_ssm_dtype": "float32",
1648 "head_dim": 4,
1649 "num_attention_heads": 1,
1650 "num_key_value_heads": 1,
1651 "max_position_embeddings": 128,
1652 "vocab_size": 32,
1653 "rms_norm_eps": 0.000001,
1654 "rope_parameters": {
1655 "rope_theta": 10000.0,
1656 "partial_rotary_factor": 1.0,
1657 "mrope_interleaved": false
1658 }
1659 });
1660 let text = text.as_object_mut().unwrap();
1661 if moe {
1662 text.insert("num_experts".to_owned(), serde_json::json!(4));
1663 text.insert("num_experts_per_tok".to_owned(), serde_json::json!(2));
1664 text.insert("moe_intermediate_size".to_owned(), serde_json::json!(8));
1665 text.insert(
1666 "shared_expert_intermediate_size".to_owned(),
1667 serde_json::json!(8),
1668 );
1669 } else {
1670 text.insert("intermediate_size".to_owned(), serde_json::json!(32));
1671 }
1672 serde_json::json!({
1673 "architectures": [if moe {
1674 "Qwen3_5MoeForConditionalGeneration"
1675 } else {
1676 "Qwen3_5ForConditionalGeneration"
1677 }],
1678 "model_type": if moe { "qwen3_5_moe" } else { "qwen3_5" },
1679 "text_config": text,
1680 "tie_word_embeddings": false
1681 })
1682 .to_string()
1683 }
1684
1685 fn value(entries: &[RuntimeConfigEntry], key: &str) -> Option<String> {
1686 entries
1687 .iter()
1688 .find(|entry| entry.key == key)
1689 .map(|entry| entry.effective_value.clone())
1690 }
1691
1692 #[tokio::test]
1693 async fn explicit_semantic_preflight_rejects_before_weight_binding() {
1694 let weights = temp_model_dir(
1695 "preflight-weights",
1696 r#"{"architectures":["Qwen3_5MoeForConditionalGeneration"]}"#,
1697 );
1698 std::fs::write(weights.join("model.safetensors"), []).unwrap();
1699 let semantic = temp_model_dir(
1700 "preflight-semantic",
1701 r#"{
1702 "architectures":["Qwen3_5MoeForConditionalGeneration"],
1703 "model_type":"qwen3_5_moe",
1704 "text_config":{"model_type":"unsupported_nested_layout"}
1705 }"#,
1706 );
1707 let args = ProductSourceArgs {
1708 semantic_source: Some(semantic.clone()),
1709 tokenizer_source: None,
1710 };
1711
1712 let error = resolve_model_source_with_product_sources(
1713 weights.to_str().unwrap(),
1714 &weights.join("unused-cache"),
1715 DownloadPolicy::NoDownload,
1716 None,
1717 &args,
1718 )
1719 .await
1720 .err()
1721 .expect("invalid semantic layout must fail before weight binding")
1722 .to_string();
1723
1724 assert!(
1725 error.contains("unsupported Qwen3.5 text model_type"),
1726 "{error}"
1727 );
1728 assert!(
1729 !error.contains("source manifest file is missing or empty"),
1730 "{error}"
1731 );
1732 let _ = std::fs::remove_dir_all(weights);
1733 let _ = std::fs::remove_dir_all(semantic);
1734 }
1735
1736 #[test]
1737 fn hf_and_gguf_aliases_are_disjoint() {
1738 for entry in GGUF_ALIASES {
1739 for alias in entry.aliases {
1740 assert_eq!(
1741 resolve_model_alias(alias),
1742 *alias,
1743 "alias '{alias}' resolves to both an HF repository and a GGUF file"
1744 );
1745 }
1746 }
1747 assert_eq!(resolve_model_alias("qwen3:1.7b"), "Qwen/Qwen3-1.7B");
1748 assert_eq!(resolve_model_alias("qwen3.5:4b"), "Qwen/Qwen3.5-4B");
1749 assert!(resolve_gguf_alias("qwen3:1.7b").is_none());
1750 assert!(resolve_gguf_alias("qwen3:1.7b-gguf").is_some());
1751 assert_eq!(
1752 resolve_gguf_alias("qwen3.5:4b-q4_k_m"),
1753 Some((
1754 "unsloth/Qwen3.5-4B-GGUF".to_string(),
1755 "Qwen3.5-4B-Q4_K_M.gguf".to_string()
1756 ))
1757 );
1758 assert_eq!(
1759 resolve_gguf_alias("qwen3.5:35b-a3b-q4_k_s"),
1760 Some((
1761 "unsloth/Qwen3.5-35B-A3B-GGUF".to_string(),
1762 "Qwen3.5-35B-A3B-Q4_K_S.gguf".to_string()
1763 ))
1764 );
1765 }
1766
1767 #[tokio::test]
1768 async fn resolves_local_model_directory_with_stable_product_id() {
1769 let config = qwen35_semantic_config(false);
1770 let dir = temp_model_dir("local-product-id", &config);
1771 std::fs::write(dir.join("model.safetensors"), b"fixture-weights").unwrap();
1772
1773 let resolved = resolve_model_source(
1774 dir.to_str().unwrap(),
1775 &dir.join("unused-cache"),
1776 DownloadPolicy::NoDownload,
1777 None,
1778 )
1779 .await
1780 .unwrap();
1781 let product = resolved.into_product_engine_input();
1782
1783 assert_eq!(product.source.local_path, dir);
1784 assert_eq!(product.source.format, ModelFormat::SafeTensors);
1785 assert!(!product.source.from_cache);
1786 assert!(!product.autosized);
1787 let sources = product.model_sources.as_ref().unwrap();
1788 assert_eq!(sources.semantic_root(), dir.canonicalize().unwrap());
1789 assert_eq!(sources.tokenizer_root(), dir.canonicalize().unwrap());
1790 assert!(matches!(
1791 product.engine_config.model.source.as_ref().unwrap(),
1792 ModelSource::Local(path) if path == dir.to_str().unwrap()
1793 ));
1794 assert_eq!(
1795 product.engine_config.model.model_id.as_str(),
1796 dir.file_name().unwrap().to_string_lossy()
1797 );
1798 let _ = std::fs::remove_dir_all(dir);
1799 }
1800
1801 #[tokio::test]
1802 async fn explicit_semantic_source_replaces_metadata_roles_not_weights() {
1803 let weights = temp_model_dir(
1804 "explicit-role-weights",
1805 r#"{"architectures":["Qwen3_5MoeForConditionalGeneration"],"quantization_config":{"quant_method":"gptq"}}"#,
1806 );
1807 std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
1808 let semantic_config = qwen35_semantic_config(true);
1809 let semantic = temp_model_dir("explicit-role-semantic", &semantic_config);
1810 std::fs::write(
1811 semantic.join("tokenizer_config.json"),
1812 br#"{"chat_template":"fixture"}"#,
1813 )
1814 .unwrap();
1815 let args = ProductSourceArgs {
1816 semantic_source: Some(semantic.clone()),
1817 tokenizer_source: None,
1818 };
1819
1820 let resolved = resolve_model_source_with_product_sources(
1821 weights.to_str().unwrap(),
1822 &weights.join("unused-cache"),
1823 DownloadPolicy::NoDownload,
1824 None,
1825 &args,
1826 )
1827 .await
1828 .unwrap();
1829 let product = resolved.into_product_engine_input();
1830 let sources = product.model_sources.unwrap();
1831 assert_eq!(product.requested_model, weights.display().to_string());
1832 assert_eq!(sources.semantic_root(), semantic.canonicalize().unwrap());
1833 assert_eq!(sources.tokenizer_root(), semantic.canonicalize().unwrap());
1834 assert_eq!(sources.weights().path(), weights.canonicalize().unwrap());
1835 assert!(sources
1836 .fingerprint(
1837 ferrum_interfaces::vnext::ModelArtifactSourceRole::Weights,
1838 "config.json",
1839 )
1840 .is_some());
1841 let _ = std::fs::remove_dir_all(weights);
1842 let _ = std::fs::remove_dir_all(semantic);
1843 }
1844
1845 #[test]
1846 fn direct_huggingface_snapshot_uses_stable_repository_public_id() {
1847 let revision = "a".repeat(40);
1848 let source = ResolvedModelSource {
1849 original: "/cache/models--Qwen--Qwen3.5-35B-A3B-GPTQ-Int4/snapshots/local".to_owned(),
1850 local_path: PathBuf::from(format!(
1851 "/cache/models--Qwen--Qwen3.5-35B-A3B-GPTQ-Int4/snapshots/{revision}"
1852 )),
1853 format: ModelFormat::SafeTensors,
1854 from_cache: false,
1855 };
1856
1857 assert_eq!(public_model_id(&source), "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4");
1858
1859 let gguf_source = ResolvedModelSource {
1860 original: "/cache/models--unsloth--Qwen3.5-35B-A3B-GGUF/snapshots/local/model.gguf"
1861 .to_owned(),
1862 local_path: PathBuf::from(format!(
1863 "/cache/models--unsloth--Qwen3.5-35B-A3B-GGUF/snapshots/{revision}/model.gguf"
1864 )),
1865 format: ModelFormat::GGUF,
1866 from_cache: false,
1867 };
1868 assert_eq!(
1869 public_model_id(&gguf_source),
1870 "unsloth/Qwen3.5-35B-A3B-GGUF"
1871 );
1872 }
1873
1874 #[tokio::test]
1875 async fn resolves_direct_gguf_package_with_file_stem_product_id() {
1876 let config = qwen35_semantic_config(false);
1877 let dir = temp_model_dir("direct-gguf-package", &config);
1878 let gguf = dir.join("Qwen3.5-4B-Instruct-Q4_K_M.gguf");
1879 std::fs::write(&gguf, b"fixture-gguf").unwrap();
1880
1881 let resolved = resolve_model_source(
1882 gguf.to_str().unwrap(),
1883 &dir.join("unused-cache"),
1884 DownloadPolicy::NoDownload,
1885 None,
1886 )
1887 .await
1888 .unwrap();
1889 let product = resolved.into_product_engine_input();
1890
1891 assert_eq!(product.source.local_path, gguf);
1892 assert_eq!(product.source.format, ModelFormat::GGUF);
1893 let sources = product.model_sources.as_ref().unwrap();
1894 assert_eq!(sources.semantic_root(), dir.canonicalize().unwrap());
1895 assert_eq!(sources.tokenizer_root(), dir.canonicalize().unwrap());
1896 assert_eq!(sources.weights().path(), gguf.canonicalize().unwrap());
1897 assert!(matches!(
1898 ferrum_models::vnext::resolve_registered_model_from_sources(sources).unwrap(),
1899 ferrum_models::vnext::ProductionModelRegistration::Registered(_)
1900 ));
1901 assert!(matches!(
1902 product.engine_config.model.source.as_ref().unwrap(),
1903 ModelSource::Local(path) if path == gguf.to_str().unwrap()
1904 ));
1905 assert_eq!(
1906 product.engine_config.model.model_id.as_str(),
1907 "Qwen3.5-4B-Instruct-Q4_K_M"
1908 );
1909 let _ = std::fs::remove_dir_all(dir);
1910 }
1911
1912 #[tokio::test]
1913 async fn unresolved_direct_gguf_keeps_legacy_compatibility_for_unmigrated_architectures() {
1914 let nonce = SystemTime::now()
1915 .duration_since(UNIX_EPOCH)
1916 .unwrap()
1917 .as_nanos();
1918 let dir = std::env::temp_dir().join(format!(
1919 "ferrum-source-resolver-untyped-gguf-{}-{nonce}",
1920 std::process::id()
1921 ));
1922 std::fs::create_dir_all(&dir).unwrap();
1923 let gguf = dir.join("legacy-model.gguf");
1924 std::fs::write(&gguf, []).unwrap();
1925
1926 let resolved = resolve_model_source(
1927 gguf.to_str().unwrap(),
1928 &dir.join("unused-cache"),
1929 DownloadPolicy::NoDownload,
1930 None,
1931 )
1932 .await
1933 .unwrap();
1934 let product = resolved.into_product_engine_input();
1935
1936 assert!(product.model_sources.is_none());
1937 assert_eq!(product.source.local_path, gguf);
1938 let _ = std::fs::remove_dir_all(dir);
1939 }
1940
1941 #[tokio::test]
1942 async fn resolves_cached_gguf_alias_without_entrypoint_short_circuit() {
1943 let cache = temp_model_dir("cached-gguf-alias", r#"{}"#);
1944 let (repo, filename) = resolve_gguf_alias("qwen3:4b-q4_k_m").unwrap();
1945 let repo_dir = cache
1946 .join("hub")
1947 .join(format!("models--{}", repo.replace('/', "--")));
1948 let revision = "fixture-revision";
1949 let snapshot = repo_dir.join("snapshots").join(revision);
1950 std::fs::create_dir_all(&snapshot).unwrap();
1951 std::fs::create_dir_all(repo_dir.join("refs")).unwrap();
1952 std::fs::write(repo_dir.join("refs/main"), revision).unwrap();
1953 let gguf = snapshot.join(&filename);
1954 std::fs::write(&gguf, b"fixture-gguf").unwrap();
1955
1956 let metadata_repo = tokenizer_sibling_repo(&repo).unwrap();
1957 let metadata_repo_dir = cache
1958 .join("hub")
1959 .join(format!("models--{}", metadata_repo.replace('/', "--")));
1960 let metadata_revision = "metadata-fixture-revision";
1961 let metadata_snapshot = metadata_repo_dir.join("snapshots").join(metadata_revision);
1962 std::fs::create_dir_all(&metadata_snapshot).unwrap();
1963 std::fs::create_dir_all(metadata_repo_dir.join("refs")).unwrap();
1964 std::fs::write(metadata_repo_dir.join("refs/main"), metadata_revision).unwrap();
1965 std::fs::write(
1966 metadata_snapshot.join("config.json"),
1967 br#"{"architectures":["Qwen3ForCausalLM"]}"#,
1968 )
1969 .unwrap();
1970 std::fs::write(
1971 metadata_snapshot.join("tokenizer.json"),
1972 br#"{"version":"1.0"}"#,
1973 )
1974 .unwrap();
1975 std::fs::write(
1976 metadata_snapshot.join("tokenizer_config.json"),
1977 br#"{"chat_template":"fixture-template"}"#,
1978 )
1979 .unwrap();
1980
1981 let resolved =
1982 resolve_model_source("qwen3:4b-q4_k_m", &cache, DownloadPolicy::NoDownload, None)
1983 .await
1984 .unwrap();
1985 let product = resolved.into_product_engine_input();
1986
1987 assert_eq!(product.source.local_path, gguf);
1988 assert_eq!(product.source.format, ModelFormat::GGUF);
1989 assert!(product.source.from_cache);
1990 let sources = product.model_sources.as_ref().unwrap();
1991 assert_eq!(
1992 sources.semantic_root(),
1993 metadata_snapshot.canonicalize().unwrap()
1994 );
1995 assert_eq!(
1996 sources.tokenizer_root(),
1997 metadata_snapshot.canonicalize().unwrap()
1998 );
1999 assert_eq!(sources.weights().path(), gguf.canonicalize().unwrap());
2000 assert_eq!(sources.original_sources().semantic.location, metadata_repo);
2001 assert_eq!(sources.original_sources().weights.location, repo);
2002 assert!(!snapshot.join("tokenizer.json").exists());
2003 assert!(matches!(
2004 product.engine_config.model.source.as_ref().unwrap(),
2005 ModelSource::HuggingFace { repo_id, revision: None, cache_dir: Some(root) }
2006 if repo_id == &repo && root == &cache.display().to_string()
2007 ));
2008 assert_eq!(
2009 product.engine_config.model.model_id.as_str(),
2010 Path::new(&filename).file_stem().unwrap().to_string_lossy()
2011 );
2012 let _ = std::fs::remove_dir_all(cache);
2013 }
2014
2015 #[test]
2016 fn serve_profile_defaults_metal_gguf_moe_without_user_env() {
2017 let entries = serve_profile_runtime_entries_for_arch(
2018 true,
2019 true,
2020 true,
2021 false,
2022 &RuntimeConfigSnapshot::default(),
2023 RuntimeConfigSource::Default,
2024 );
2025
2026 assert_eq!(
2027 value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2028 Some("1024")
2029 );
2030 assert_eq!(
2031 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2032 Some("16")
2033 );
2034 assert_eq!(
2035 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2036 Some("1")
2037 );
2038 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2039 assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("16"));
2040 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("1"));
2041 assert_eq!(
2042 value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2043 Some("1")
2044 );
2045 }
2046
2047 #[test]
2048 fn serve_profile_keeps_multi_seq_default_for_non_metal_moe() {
2049 let entries = serve_profile_runtime_entries_for_arch(
2050 true,
2051 true,
2052 false,
2053 false,
2054 &RuntimeConfigSnapshot::default(),
2055 RuntimeConfigSource::Default,
2056 );
2057
2058 assert_eq!(
2059 value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2060 Some("2048")
2061 );
2062 assert_eq!(
2063 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2064 Some("16")
2065 );
2066 assert_eq!(
2067 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2068 Some("1")
2069 );
2070 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2071 assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("16"));
2072 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("1"));
2073 assert_eq!(
2074 value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2075 Some("1")
2076 );
2077 }
2078
2079 #[test]
2080 fn serve_profile_defaults_gguf_dense_without_moe_env() {
2081 let entries = serve_profile_runtime_entries_for_arch(
2082 true,
2083 false,
2084 true,
2085 false,
2086 &RuntimeConfigSnapshot::default(),
2087 RuntimeConfigSource::Default,
2088 );
2089
2090 assert_eq!(
2091 value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2092 Some("512")
2093 );
2094 assert_eq!(
2095 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2096 Some("16")
2097 );
2098 assert_eq!(
2099 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2100 Some("1")
2101 );
2102 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2103 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED"), None);
2104 }
2105
2106 #[test]
2107 fn serve_profile_leaves_context_capacity_to_vnext_plan() {
2108 let entries = serve_profile_runtime_entries_for_arch(
2109 true,
2110 false,
2111 true,
2112 true,
2113 &RuntimeConfigSnapshot::default(),
2114 RuntimeConfigSource::Default,
2115 );
2116
2117 assert_eq!(value(&entries, "FERRUM_KV_CAPACITY"), None);
2118 assert_eq!(
2119 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2120 Some("16")
2121 );
2122 assert_eq!(
2123 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2124 Some("1")
2125 );
2126 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2127 assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("16"));
2128 }
2129
2130 #[test]
2131 fn serve_profile_respects_explicit_user_env() {
2132 let current = RuntimeConfigSnapshot::from_entries(vec![RuntimeConfigEntry::new(
2133 "FERRUM_KV_CAPACITY",
2134 "4096",
2135 RuntimeConfigSource::Default,
2136 )]);
2137 let entries = serve_profile_runtime_entries_for_arch(
2138 true,
2139 true,
2140 true,
2141 false,
2142 ¤t,
2143 RuntimeConfigSource::Default,
2144 );
2145
2146 assert_eq!(value(&entries, "FERRUM_KV_CAPACITY"), None);
2147 assert_eq!(
2148 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2149 Some("16")
2150 );
2151 }
2152
2153 #[test]
2154 fn chat_profile_defaults_dense_safetensors_as_typed_entries() {
2155 let dir = temp_model_dir(
2156 "dense",
2157 r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2158 );
2159 let entries = chat_profile_runtime_entries(
2160 &dir,
2161 &RuntimeConfigSnapshot::default(),
2162 RuntimeConfigSource::Default,
2163 );
2164
2165 assert_eq!(
2166 value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2167 Some("8192")
2168 );
2169 assert_eq!(
2170 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2171 Some("1")
2172 );
2173 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2174 assert_eq!(
2175 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2176 Some("2")
2177 );
2178 assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("1"));
2179 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED"), None);
2180 let _ = std::fs::remove_dir_all(dir);
2181 }
2182
2183 #[test]
2184 fn chat_profile_recognizes_qwen35_dense_without_qwen3_fallback() {
2185 let dir = temp_model_dir(
2186 "qwen35_dense",
2187 r#"{"architectures":["Qwen3_5ForConditionalGeneration"],"model_type":"qwen3_5"}"#,
2188 );
2189 assert_eq!(detect_model_family(&dir).as_deref(), Some("qwen3_5"));
2190 assert!(!detect_moe_arch(&dir));
2191
2192 let entries = chat_profile_runtime_entries(
2193 &dir,
2194 &RuntimeConfigSnapshot::default(),
2195 RuntimeConfigSource::Default,
2196 );
2197
2198 assert_eq!(
2199 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2200 Some("1")
2201 );
2202 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2203 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED"), None);
2204 let _ = std::fs::remove_dir_all(dir);
2205 }
2206
2207 #[test]
2208 fn chat_profile_disables_metal_paged_kv_for_llama_safetensors() {
2209 let dir = temp_model_dir(
2210 "llama",
2211 r#"{"architectures":["LlamaForCausalLM"],"model_type":"llama"}"#,
2212 );
2213 let entries = chat_profile_runtime_entries(
2214 &dir,
2215 &RuntimeConfigSnapshot::default(),
2216 RuntimeConfigSource::Default,
2217 );
2218
2219 assert_eq!(
2220 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2221 Some("0")
2222 );
2223 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("0"));
2224 let _ = std::fs::remove_dir_all(dir);
2225 }
2226
2227 #[test]
2228 fn chat_profile_disables_metal_paged_kv_for_qwen2_safetensors() {
2229 let dir = temp_model_dir(
2230 "qwen2",
2231 r#"{"architectures":["Qwen2ForCausalLM"],"model_type":"qwen2"}"#,
2232 );
2233 let entries = chat_profile_runtime_entries(
2234 &dir,
2235 &RuntimeConfigSnapshot::default(),
2236 RuntimeConfigSource::Default,
2237 );
2238
2239 assert_eq!(
2240 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2241 Some("0")
2242 );
2243 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("0"));
2244 let _ = std::fs::remove_dir_all(dir);
2245 }
2246
2247 #[test]
2248 fn chat_profile_defaults_moe_safetensors_as_typed_entries() {
2249 let dir = temp_model_dir(
2250 "moe",
2251 r#"{"architectures":["Qwen3MoeForCausalLM"],"model_type":"qwen3_moe"}"#,
2252 );
2253 let entries = chat_profile_runtime_entries(
2254 &dir,
2255 &RuntimeConfigSnapshot::default(),
2256 RuntimeConfigSource::Default,
2257 );
2258
2259 assert_eq!(
2260 value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2261 Some("4096")
2262 );
2263 assert_eq!(
2264 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2265 Some("1")
2266 );
2267 assert_eq!(
2268 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2269 Some("1")
2270 );
2271 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2272 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2273 assert_eq!(
2274 value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2275 Some("0")
2276 );
2277 assert_eq!(
2278 value(&entries, "FERRUM_MOE_BATCH_THRESHOLD").as_deref(),
2279 Some("2")
2280 );
2281 let _ = std::fs::remove_dir_all(dir);
2282 }
2283
2284 #[test]
2285 fn chat_profile_recognizes_qwen35_moe_as_distinct_moe_family() {
2286 let dir = temp_model_dir(
2287 "qwen35_moe",
2288 r#"{"architectures":["Qwen3_5MoeForConditionalGeneration"],"model_type":"qwen3_5_moe"}"#,
2289 );
2290 assert_eq!(detect_model_family(&dir).as_deref(), Some("qwen3_5_moe"));
2291 assert!(detect_moe_arch(&dir));
2292
2293 let entries = chat_profile_runtime_entries(
2294 &dir,
2295 &RuntimeConfigSnapshot::default(),
2296 RuntimeConfigSource::Default,
2297 );
2298
2299 assert_eq!(
2300 value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2301 Some("4096")
2302 );
2303 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2304 let _ = std::fs::remove_dir_all(dir);
2305 }
2306
2307 #[test]
2308 fn chat_profile_defaults_moe_gguf_as_typed_entries() {
2309 let entries = chat_profile_runtime_entries_for_arch(
2310 true,
2311 true,
2312 Some("qwen3_moe"),
2313 &RuntimeConfigSnapshot::default(),
2314 RuntimeConfigSource::Default,
2315 );
2316
2317 assert_eq!(
2318 value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2319 Some("4096")
2320 );
2321 assert_eq!(
2322 value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2323 Some("1")
2324 );
2325 assert_eq!(
2326 value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2327 Some("0")
2328 );
2329 assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("0"));
2330 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2331 assert_eq!(
2332 value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2333 Some("0")
2334 );
2335 }
2336
2337 #[test]
2338 fn chat_profile_defaults_preserve_existing_snapshot_values() {
2339 let dir = temp_model_dir(
2340 "override",
2341 r#"{"architectures":["Qwen3MoeForCausalLM"],"model_type":"qwen3_moe"}"#,
2342 );
2343 let current = RuntimeConfigSnapshot::from_entries([
2344 RuntimeConfigEntry::new("FERRUM_KV_CAPACITY", "1234", RuntimeConfigSource::Env),
2345 RuntimeConfigEntry::new("FERRUM_MOE_BATCH_THRESHOLD", "7", RuntimeConfigSource::Env),
2346 ]);
2347 let entries = chat_profile_runtime_entries(&dir, ¤t, RuntimeConfigSource::Default);
2348
2349 assert_eq!(value(&entries, "FERRUM_KV_CAPACITY"), None);
2350 assert_eq!(value(&entries, "FERRUM_MOE_BATCH_THRESHOLD"), None);
2351 assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2352 let _ = std::fs::remove_dir_all(dir);
2353 }
2354
2355 #[test]
2356 fn load_model_chat_template_reads_tokenizer_config() {
2357 let dir = temp_model_dir(
2358 "template",
2359 r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2360 );
2361 std::fs::write(
2362 dir.join("tokenizer_config.json"),
2363 r#"{"chat_template":"{{ messages[0].content }}","bos_token":"<s>","eos_token":"</s>"}"#,
2364 )
2365 .unwrap();
2366
2367 let template = load_model_chat_template(&dir).unwrap();
2368 assert_eq!(template.template, "{{ messages[0].content }}");
2369 assert_eq!(template.bos_token.as_deref(), Some("<s>"));
2370 assert_eq!(template.eos_token.as_deref(), Some("</s>"));
2371 let _ = std::fs::remove_dir_all(dir);
2372 }
2373
2374 #[test]
2375 fn product_chat_template_uses_immutable_source_bytes() {
2376 let dir = temp_model_dir(
2377 "immutable-template",
2378 r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2379 );
2380 std::fs::write(dir.join("model.safetensors"), b"fixture-weights").unwrap();
2381 std::fs::write(
2382 dir.join("tokenizer_config.json"),
2383 r#"{"chat_template":"original-template","eos_token":"</s>"}"#,
2384 )
2385 .unwrap();
2386 let bundle = ProductionModelSourceBundle::open_colocated_safetensors(&dir).unwrap();
2387
2388 std::fs::write(
2389 dir.join("tokenizer_config.json"),
2390 r#"{"chat_template":"mutated-template"}"#,
2391 )
2392 .unwrap();
2393
2394 let template = load_product_chat_template(&bundle).unwrap();
2395 assert_eq!(template.template, "original-template");
2396 assert_eq!(template.eos_token.as_deref(), Some("</s>"));
2397 let _ = std::fs::remove_dir_all(dir);
2398 }
2399
2400 #[test]
2401 fn typed_template_source_ignores_unselected_duplicate() {
2402 let dir = temp_model_dir(
2403 "typed-template-source",
2404 r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2405 );
2406 std::fs::write(dir.join("model.safetensors"), b"fixture-weights").unwrap();
2407 std::fs::write(
2408 dir.join("tokenizer_config.json"),
2409 r#"{"chat_template":"typed-template","eos_token":"</s>"}"#,
2410 )
2411 .unwrap();
2412 std::fs::write(dir.join("chat_template.jinja"), "unselected-template").unwrap();
2413 let bundle = ProductionModelSourceBundle::open_colocated_safetensors(&dir).unwrap();
2414
2415 let template = load_product_chat_template_source(&bundle, "tokenizer_config.json").unwrap();
2416 assert_eq!(template.template, "typed-template");
2417 assert_eq!(template.eos_token.as_deref(), Some("</s>"));
2418 assert!(template.source.ends_with("tokenizer_config.json"));
2419 let _ = std::fs::remove_dir_all(dir);
2420 }
2421}