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