1use std::path::{Path, PathBuf};
7
8use crate::discovery::gguf_models::is_mmproj_name;
9use crate::discovery::modality_hints::{Hint, from_config_json};
10use crate::discovery::weights::{gguf_tree, primary_of};
11use crate::records::{
12 Capability, ExecutionMode, JsonValue, Modality, ModelRecord, ParamSpec, ParamType, RunTier,
13 RuntimeId, SourceKind,
14};
15use crate::resolution::format::{
16 GgufFacts, ModelFormat, gguf_architecture_profile, ollama_profile,
17};
18use crate::resolution::gguf::{gguf_facts, has_ggml_magic, has_gguf_magic};
19use crate::resolution::pipelines::{
20 PipelineFamilyRegistry, SchedulerFacts, diffusers_pipeline_class,
21};
22use crate::resolution::safetensors::safetensors_format;
23
24#[derive(Debug, Clone, PartialEq)]
28pub struct IdentifiedModel {
29 pub format: ModelFormat,
31 pub modality: Option<Modality>,
33 pub capabilities: Vec<Capability>,
35 pub execution: ExecutionMode,
37 pub params: Vec<ParamSpec>,
39 pub pipeline_class: Option<String>,
41 pub context_length: Option<i64>,
43 pub has_chat_template: Option<bool>,
45 pub quantization: Option<String>,
47}
48
49impl IdentifiedModel {
50 pub fn new(
52 format: ModelFormat,
53 modality: Option<Modality>,
54 capabilities: Vec<Capability>,
55 execution: ExecutionMode,
56 ) -> Self {
57 Self {
58 format,
59 modality,
60 capabilities,
61 execution,
62 params: Vec::new(),
63 pipeline_class: None,
64 context_length: None,
65 has_chat_template: None,
66 quantization: None,
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub struct RuntimeBid {
76 pub tier: RunTier,
78 pub preference: i64,
81 pub alternatives: Vec<RuntimeId>,
83}
84
85impl RuntimeBid {
86 pub fn new(tier: RunTier, preference: i64) -> Self {
88 Self {
89 tier,
90 preference,
91 alternatives: Vec::new(),
92 }
93 }
94
95 pub fn with_alternatives(tier: RunTier, preference: i64, alternatives: Vec<RuntimeId>) -> Self {
97 Self {
98 tier,
99 preference,
100 alternatives,
101 }
102 }
103}
104
105pub fn identify(record: &ModelRecord) -> IdentifiedModel {
114 let kind = &record.source.kind;
115 if *kind == SourceKind::builtin() {
116 return chat_model(ModelFormat::Builtin, builtin_params());
117 }
118 if *kind == SourceKind::endpoint() {
119 return chat_model(ModelFormat::Endpoint, endpoint_params());
120 }
121 if *kind == SourceKind::ollama() {
122 let facts = record
125 .primary_weight_path
126 .as_deref()
127 .and_then(|path| gguf_facts(Path::new(path)));
128 let profile = ollama_profile(
129 manifest_has_projector_layer(&record.source.path),
130 facts
131 .as_ref()
132 .and_then(|facts| facts.architecture.as_deref()),
133 );
134 let mut model = IdentifiedModel::new(
135 ModelFormat::OllamaStore,
136 Some(profile.modality),
137 profile.capabilities,
138 profile.execution,
139 );
140 model.quantization = facts.and_then(|facts| facts.quantization);
141 return model;
142 }
143
144 let base = Path::new(&record.source.path);
145 let container = container_url(base, record);
146 let extension = base
147 .extension()
148 .and_then(|ext| ext.to_str())
149 .map(str::to_ascii_lowercase);
150
151 if extension.as_deref() == Some("bin") && has_ggml_magic(base) {
152 return IdentifiedModel::new(
153 ModelFormat::GgmlBin,
154 Some(Modality::audio()),
155 vec![Capability::transcribe()],
156 ExecutionMode::Stream,
157 );
158 }
159
160 if extension.as_deref() == Some("gguf") || has_gguf_magic(base) {
161 return identify_gguf(base, has_mmproj_companion(base));
162 }
163
164 let model_index = container.join("model_index.json");
165 if model_index.exists() {
166 return identify_diffusers(&model_index, &container, record);
167 }
168
169 let config = container.join("config.json");
170 let hint = from_config_json(&config);
171 if let Some(format) = safetensors_format(&container, &config) {
172 return identify_safetensors(format, hint.as_ref(), &container);
173 }
174 if let Some((weights, has_projector)) =
180 gguf_weights(&container, record.primary_weight_path.as_deref())
181 {
182 return identify_gguf(&weights, has_projector);
183 }
184 match hint {
185 Some(hint) => {
186 let mut model = IdentifiedModel::new(
187 ModelFormat::Unknown,
188 hint.modality,
189 hint.capabilities,
190 hint.execution,
191 );
192 model.context_length = hint.context_length;
193 model
194 }
195 None => IdentifiedModel::new(ModelFormat::Unknown, None, Vec::new(), ExecutionMode::Sync),
196 }
197}
198
199fn gguf_weights(container: &Path, primary: Option<&str>) -> Option<(PathBuf, bool)> {
217 if let Some(primary) = primary
218 && !has_gguf_magic(Path::new(primary))
219 {
220 return None;
221 }
222 let tree = gguf_tree(container);
223 let weights = primary_of(&tree.weights)?;
224 Some((weights, tree.has_projector))
225}
226
227fn identify_gguf(base: &Path, has_projector: bool) -> IdentifiedModel {
231 let name = base
232 .file_name()
233 .and_then(|name| name.to_str())
234 .unwrap_or_default();
235 if is_mmproj_name(name) {
236 return IdentifiedModel::new(
238 ModelFormat::Gguf,
239 Some(Modality::vision()),
240 Vec::new(),
241 ExecutionMode::Sync,
242 );
243 }
244 let facts = gguf_facts(base);
245 if let Some(architecture) = facts
246 .as_ref()
247 .and_then(|facts| facts.architecture.as_deref())
248 && let Some(profile) = gguf_architecture_profile(architecture)
249 {
250 let mut capabilities = profile.capabilities;
251 if !has_projector {
252 capabilities.retain(|capability| *capability != Capability::see());
256 }
257 let mut model = IdentifiedModel::new(
258 ModelFormat::Gguf,
259 Some(profile.modality),
260 capabilities,
261 profile.execution,
262 );
263 apply_facts(&mut model, facts.as_ref());
264 return model;
265 }
266 let capabilities = if has_projector {
267 vec![
268 Capability::chat(),
269 Capability::complete(),
270 Capability::see(),
271 ]
272 } else {
273 vec![Capability::chat(), Capability::complete()]
274 };
275 let mut model = IdentifiedModel::new(
276 ModelFormat::Gguf,
277 Some(Modality::text()),
278 capabilities,
279 ExecutionMode::Stream,
280 );
281 apply_facts(&mut model, facts.as_ref());
282 model
283}
284
285fn identify_safetensors(
286 format: ModelFormat,
287 hint: Option<&Hint>,
288 container: &Path,
289) -> IdentifiedModel {
290 let hint_modality = hint.and_then(|hint| hint.modality.clone());
291 let text = Some(Modality::text());
292 if (hint_modality.is_none() || hint_modality == text)
293 && has_sentence_transformers_layout(container)
294 {
295 let mut model = IdentifiedModel::new(
296 format,
297 Some(Modality::embedding()),
298 vec![Capability::embed()],
299 ExecutionMode::Stream,
300 );
301 apply_hint(&mut model, hint);
302 return model;
303 }
304 let mut model = IdentifiedModel::new(
305 format,
306 hint.and_then(|hint| hint.modality.clone()),
307 hint.map(|hint| hint.capabilities.clone())
308 .unwrap_or_default(),
309 hint.map_or(ExecutionMode::Sync, |hint| hint.execution),
310 );
311 apply_hint(&mut model, hint);
312 model
313}
314
315fn apply_facts(model: &mut IdentifiedModel, facts: Option<&GgufFacts>) {
318 model.context_length = facts.and_then(|facts| facts.context_length);
319 model.has_chat_template = facts.map(|facts| facts.has_chat_template);
320 model.quantization = facts.and_then(|facts| facts.quantization.clone());
321}
322
323fn apply_hint(model: &mut IdentifiedModel, hint: Option<&Hint>) {
326 model.context_length = hint.and_then(|hint| hint.context_length);
327 model.quantization = hint.and_then(|hint| hint.quantization.clone());
328}
329
330fn chat_model(format: ModelFormat, params: Vec<ParamSpec>) -> IdentifiedModel {
331 let mut model = IdentifiedModel::new(
332 format,
333 Some(Modality::text()),
334 vec![Capability::chat(), Capability::complete()],
335 ExecutionMode::Stream,
336 );
337 model.params = params;
338 model
339}
340
341fn container_url(base: &Path, record: &ModelRecord) -> PathBuf {
344 if record.source.kind == SourceKind::huggingface_cache()
345 && let Some(reference) = &record.source.reference
346 {
347 let snapshot = base.join("snapshots").join(reference);
348 if snapshot.exists() {
349 return snapshot;
350 }
351 }
352 base.to_path_buf()
353}
354
355fn manifest_has_projector_layer(path: &str) -> bool {
357 let Ok(bytes) = std::fs::read(path) else {
358 return false;
359 };
360 let Ok(JsonValue::Object(object)) = serde_json::from_slice::<JsonValue>(&bytes) else {
361 return false;
362 };
363 let Some(JsonValue::Array(layers)) = object.get("layers") else {
364 return false;
365 };
366 layers.iter().any(|layer| {
367 layer
368 .as_object()
369 .and_then(|fields| fields.get("mediaType"))
370 .and_then(JsonValue::as_str)
371 .is_some_and(|media| media.ends_with(".projector"))
372 })
373}
374
375fn has_mmproj_companion(base: &Path) -> bool {
377 let Some(directory) = base.parent() else {
378 return false;
379 };
380 let base_name = base.file_name().and_then(|name| name.to_str());
381 let Ok(entries) = std::fs::read_dir(directory) else {
382 return false;
383 };
384 entries.flatten().any(|entry| {
385 let path = entry.path();
386 let name = path.file_name().and_then(|name| name.to_str());
387 name != base_name
388 && name.is_some_and(|name| !name.starts_with('.') && is_mmproj_name(name))
390 && path
391 .extension()
392 .and_then(|ext| ext.to_str())
393 .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
394 })
395}
396
397fn has_sentence_transformers_layout(container: &Path) -> bool {
398 const MARKERS: [&str; 2] = ["config_sentence_transformers.json", "1_Pooling"];
399 let Ok(entries) = std::fs::read_dir(container) else {
400 return false;
401 };
402 entries.flatten().any(|entry| {
403 entry
404 .file_name()
405 .to_str()
406 .is_some_and(|name| MARKERS.contains(&name))
407 })
408}
409
410fn identify_diffusers(
415 model_index: &Path,
416 container: &Path,
417 record: &ModelRecord,
418) -> IdentifiedModel {
419 let pipeline_class = diffusers_pipeline_class(model_index);
420 let scheduler = scheduler_facts(container);
421 let repo_hint = record.source.repo.as_deref().unwrap_or(&record.name);
422 let profile = pipeline_class.as_deref().and_then(|class| {
423 PipelineFamilyRegistry::shared().profile(class, scheduler.as_ref(), Some(repo_hint))
424 });
425 let Some(profile) = profile else {
426 let mut model =
427 IdentifiedModel::new(ModelFormat::Diffusers, None, Vec::new(), ExecutionMode::Job);
428 model.pipeline_class = pipeline_class;
429 return model;
430 };
431 let mut params = profile.params;
432 if pipeline_class.as_deref() == Some("FluxPipeline") && !flux_uses_guidance(container) {
435 params.retain(|spec| spec.key != "guidance");
436 }
437 let mut model = IdentifiedModel::new(
438 ModelFormat::Diffusers,
439 Some(profile.modality),
440 profile.capabilities,
441 ExecutionMode::Job,
442 );
443 model.params = params;
444 model.pipeline_class = pipeline_class;
445 model
446}
447
448fn scheduler_facts(container: &Path) -> Option<SchedulerFacts> {
450 let path = container.join("scheduler").join("scheduler_config.json");
451 let bytes = std::fs::read(path).ok()?;
452 let JsonValue::Object(config) = serde_json::from_slice::<JsonValue>(&bytes).ok()? else {
453 return None;
454 };
455 Some(SchedulerFacts::new(
456 config
457 .get("_class_name")
458 .and_then(JsonValue::as_str)
459 .map(str::to_owned),
460 config
461 .get("timestep_spacing")
462 .and_then(JsonValue::as_str)
463 .map(str::to_owned),
464 ))
465}
466
467fn flux_uses_guidance(container: &Path) -> bool {
470 let path = container.join("transformer").join("config.json");
471 let Ok(bytes) = std::fs::read(path) else {
472 return false;
473 };
474 let Ok(JsonValue::Object(config)) = serde_json::from_slice::<JsonValue>(&bytes) else {
475 return false;
476 };
477 config
478 .get("guidance_embeds")
479 .and_then(JsonValue::as_bool)
480 .unwrap_or(false)
481}
482
483fn param(key: &str, param_type: ParamType, range: Option<Vec<JsonValue>>) -> ParamSpec {
484 ParamSpec {
485 key: key.to_owned(),
486 param_type,
487 default_value: None,
488 range,
489 values: None,
490 }
491}
492
493fn builtin_params() -> Vec<ParamSpec> {
494 vec![
495 param(
496 "temperature",
497 ParamType::Float,
498 Some(vec![JsonValue::Double(0.0), JsonValue::Double(2.0)]),
499 ),
500 param(
501 "top_p",
502 ParamType::Float,
503 Some(vec![JsonValue::Double(0.0), JsonValue::Double(1.0)]),
504 ),
505 param(
506 "top_k",
507 ParamType::Int,
508 Some(vec![JsonValue::Int(0), JsonValue::Int(100)]),
509 ),
510 param(
511 "max_tokens",
512 ParamType::Int,
513 Some(vec![JsonValue::Int(1), JsonValue::Int(4096)]),
514 ),
515 param("seed", ParamType::Int, None),
516 ]
517}
518
519fn endpoint_params() -> Vec<ParamSpec> {
520 vec![
521 param(
522 "temperature",
523 ParamType::Float,
524 Some(vec![JsonValue::Double(0.0), JsonValue::Double(2.0)]),
525 ),
526 param(
527 "top_p",
528 ParamType::Float,
529 Some(vec![JsonValue::Double(0.0), JsonValue::Double(1.0)]),
530 ),
531 param(
532 "max_tokens",
533 ParamType::Int,
534 Some(vec![JsonValue::Int(1), JsonValue::Int(32768)]),
535 ),
536 param("stop", ParamType::String, None),
537 param("seed", ParamType::Int, None),
538 param(
539 "frequency_penalty",
540 ParamType::Float,
541 Some(vec![JsonValue::Double(-2.0), JsonValue::Double(2.0)]),
542 ),
543 param(
544 "presence_penalty",
545 ParamType::Float,
546 Some(vec![JsonValue::Double(-2.0), JsonValue::Double(2.0)]),
547 ),
548 ]
549}