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::ModelFormat;
16use crate::resolution::format::{gguf_architecture_profile, ollama_profile};
17use crate::resolution::gguf::{gguf_facts, has_ggml_magic, has_gguf_magic};
18use crate::resolution::pipelines::{
19 PipelineFamilyRegistry, SchedulerFacts, diffusers_pipeline_class,
20};
21use crate::resolution::safetensors::safetensors_format;
22
23#[derive(Debug, Clone, PartialEq)]
27pub struct IdentifiedModel {
28 pub format: ModelFormat,
30 pub modality: Option<Modality>,
32 pub capabilities: Vec<Capability>,
34 pub execution: ExecutionMode,
36 pub params: Vec<ParamSpec>,
38 pub pipeline_class: Option<String>,
40 pub context_length: Option<i64>,
42 pub has_chat_template: Option<bool>,
44}
45
46impl IdentifiedModel {
47 pub fn new(
49 format: ModelFormat,
50 modality: Option<Modality>,
51 capabilities: Vec<Capability>,
52 execution: ExecutionMode,
53 ) -> Self {
54 Self {
55 format,
56 modality,
57 capabilities,
58 execution,
59 params: Vec::new(),
60 pipeline_class: None,
61 context_length: None,
62 has_chat_template: None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
71pub struct RuntimeBid {
72 pub tier: RunTier,
74 pub preference: i64,
77 pub alternatives: Vec<RuntimeId>,
79}
80
81impl RuntimeBid {
82 pub fn new(tier: RunTier, preference: i64) -> Self {
84 Self {
85 tier,
86 preference,
87 alternatives: Vec::new(),
88 }
89 }
90
91 pub fn with_alternatives(tier: RunTier, preference: i64, alternatives: Vec<RuntimeId>) -> Self {
93 Self {
94 tier,
95 preference,
96 alternatives,
97 }
98 }
99}
100
101pub fn identify(record: &ModelRecord) -> IdentifiedModel {
110 let kind = &record.source.kind;
111 if *kind == SourceKind::builtin() {
112 return chat_model(ModelFormat::Builtin, builtin_params());
113 }
114 if *kind == SourceKind::endpoint() {
115 return chat_model(ModelFormat::Endpoint, endpoint_params());
116 }
117 if *kind == SourceKind::ollama() {
118 let profile = ollama_profile(
119 manifest_has_projector_layer(&record.source.path),
120 record.primary_weight_path.as_deref(),
121 );
122 return IdentifiedModel::new(
123 ModelFormat::OllamaStore,
124 Some(profile.modality),
125 profile.capabilities,
126 profile.execution,
127 );
128 }
129
130 let base = Path::new(&record.source.path);
131 let container = container_url(base, record);
132 let extension = base
133 .extension()
134 .and_then(|ext| ext.to_str())
135 .map(str::to_ascii_lowercase);
136
137 if extension.as_deref() == Some("bin") && has_ggml_magic(base) {
138 return IdentifiedModel::new(
139 ModelFormat::GgmlBin,
140 Some(Modality::audio()),
141 vec![Capability::transcribe()],
142 ExecutionMode::Stream,
143 );
144 }
145
146 if extension.as_deref() == Some("gguf") || has_gguf_magic(base) {
147 return identify_gguf(base, has_mmproj_companion(base));
148 }
149
150 let model_index = container.join("model_index.json");
151 if model_index.exists() {
152 return identify_diffusers(&model_index, &container, record);
153 }
154
155 let config = container.join("config.json");
156 let hint = from_config_json(&config);
157 if let Some(format) = safetensors_format(&container, &config) {
158 return identify_safetensors(format, hint.as_ref(), &container);
159 }
160 if let Some((weights, has_projector)) =
166 gguf_weights(&container, record.primary_weight_path.as_deref())
167 {
168 return identify_gguf(&weights, has_projector);
169 }
170 match hint {
171 Some(hint) => {
172 let mut model = IdentifiedModel::new(
173 ModelFormat::Unknown,
174 hint.modality,
175 hint.capabilities,
176 hint.execution,
177 );
178 model.context_length = hint.context_length;
179 model
180 }
181 None => IdentifiedModel::new(ModelFormat::Unknown, None, Vec::new(), ExecutionMode::Sync),
182 }
183}
184
185fn gguf_weights(container: &Path, primary: Option<&str>) -> Option<(PathBuf, bool)> {
203 if let Some(primary) = primary
204 && !has_gguf_magic(Path::new(primary))
205 {
206 return None;
207 }
208 let tree = gguf_tree(container);
209 let weights = primary_of(&tree.weights)?;
210 Some((weights, tree.has_projector))
211}
212
213fn identify_gguf(base: &Path, has_projector: bool) -> IdentifiedModel {
217 let name = base
218 .file_name()
219 .and_then(|name| name.to_str())
220 .unwrap_or_default();
221 if is_mmproj_name(name) {
222 return IdentifiedModel::new(
224 ModelFormat::Gguf,
225 Some(Modality::vision()),
226 Vec::new(),
227 ExecutionMode::Sync,
228 );
229 }
230 let facts = gguf_facts(base);
231 if let Some(architecture) = facts
232 .as_ref()
233 .and_then(|facts| facts.architecture.as_deref())
234 && let Some(profile) = gguf_architecture_profile(architecture)
235 {
236 let mut capabilities = profile.capabilities;
237 if !has_projector {
238 capabilities.retain(|capability| *capability != Capability::see());
242 }
243 let mut model = IdentifiedModel::new(
244 ModelFormat::Gguf,
245 Some(profile.modality),
246 capabilities,
247 profile.execution,
248 );
249 model.context_length = facts.as_ref().and_then(|facts| facts.context_length);
250 model.has_chat_template = facts.as_ref().map(|facts| facts.has_chat_template);
251 return model;
252 }
253 let capabilities = if has_projector {
254 vec![
255 Capability::chat(),
256 Capability::complete(),
257 Capability::see(),
258 ]
259 } else {
260 vec![Capability::chat(), Capability::complete()]
261 };
262 let mut model = IdentifiedModel::new(
263 ModelFormat::Gguf,
264 Some(Modality::text()),
265 capabilities,
266 ExecutionMode::Stream,
267 );
268 model.context_length = facts.as_ref().and_then(|facts| facts.context_length);
269 model.has_chat_template = facts.as_ref().map(|facts| facts.has_chat_template);
270 model
271}
272
273fn identify_safetensors(
274 format: ModelFormat,
275 hint: Option<&Hint>,
276 container: &Path,
277) -> IdentifiedModel {
278 let hint_modality = hint.and_then(|hint| hint.modality.clone());
279 let text = Some(Modality::text());
280 if (hint_modality.is_none() || hint_modality == text)
281 && has_sentence_transformers_layout(container)
282 {
283 let mut model = IdentifiedModel::new(
284 format,
285 Some(Modality::embedding()),
286 vec![Capability::embed()],
287 ExecutionMode::Stream,
288 );
289 model.context_length = hint.and_then(|hint| hint.context_length);
290 return model;
291 }
292 let mut model = IdentifiedModel::new(
293 format,
294 hint.and_then(|hint| hint.modality.clone()),
295 hint.map(|hint| hint.capabilities.clone())
296 .unwrap_or_default(),
297 hint.map_or(ExecutionMode::Sync, |hint| hint.execution),
298 );
299 model.context_length = hint.and_then(|hint| hint.context_length);
300 model
301}
302
303fn chat_model(format: ModelFormat, params: Vec<ParamSpec>) -> IdentifiedModel {
304 let mut model = IdentifiedModel::new(
305 format,
306 Some(Modality::text()),
307 vec![Capability::chat(), Capability::complete()],
308 ExecutionMode::Stream,
309 );
310 model.params = params;
311 model
312}
313
314fn container_url(base: &Path, record: &ModelRecord) -> PathBuf {
317 if record.source.kind == SourceKind::huggingface_cache()
318 && let Some(reference) = &record.source.reference
319 {
320 let snapshot = base.join("snapshots").join(reference);
321 if snapshot.exists() {
322 return snapshot;
323 }
324 }
325 base.to_path_buf()
326}
327
328fn manifest_has_projector_layer(path: &str) -> bool {
330 let Ok(bytes) = std::fs::read(path) else {
331 return false;
332 };
333 let Ok(JsonValue::Object(object)) = serde_json::from_slice::<JsonValue>(&bytes) else {
334 return false;
335 };
336 let Some(JsonValue::Array(layers)) = object.get("layers") else {
337 return false;
338 };
339 layers.iter().any(|layer| {
340 layer
341 .as_object()
342 .and_then(|fields| fields.get("mediaType"))
343 .and_then(JsonValue::as_str)
344 .is_some_and(|media| media.ends_with(".projector"))
345 })
346}
347
348fn has_mmproj_companion(base: &Path) -> bool {
350 let Some(directory) = base.parent() else {
351 return false;
352 };
353 let base_name = base.file_name().and_then(|name| name.to_str());
354 let Ok(entries) = std::fs::read_dir(directory) else {
355 return false;
356 };
357 entries.flatten().any(|entry| {
358 let path = entry.path();
359 let name = path.file_name().and_then(|name| name.to_str());
360 name != base_name
361 && name.is_some_and(|name| !name.starts_with('.') && is_mmproj_name(name))
363 && path
364 .extension()
365 .and_then(|ext| ext.to_str())
366 .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
367 })
368}
369
370fn has_sentence_transformers_layout(container: &Path) -> bool {
371 const MARKERS: [&str; 2] = ["config_sentence_transformers.json", "1_Pooling"];
372 let Ok(entries) = std::fs::read_dir(container) else {
373 return false;
374 };
375 entries.flatten().any(|entry| {
376 entry
377 .file_name()
378 .to_str()
379 .is_some_and(|name| MARKERS.contains(&name))
380 })
381}
382
383fn identify_diffusers(
388 model_index: &Path,
389 container: &Path,
390 record: &ModelRecord,
391) -> IdentifiedModel {
392 let pipeline_class = diffusers_pipeline_class(model_index);
393 let scheduler = scheduler_facts(container);
394 let repo_hint = record.source.repo.as_deref().unwrap_or(&record.name);
395 let profile = pipeline_class.as_deref().and_then(|class| {
396 PipelineFamilyRegistry::shared().profile(class, scheduler.as_ref(), Some(repo_hint))
397 });
398 let Some(profile) = profile else {
399 let mut model =
400 IdentifiedModel::new(ModelFormat::Diffusers, None, Vec::new(), ExecutionMode::Job);
401 model.pipeline_class = pipeline_class;
402 return model;
403 };
404 let mut params = profile.params;
405 if pipeline_class.as_deref() == Some("FluxPipeline") && !flux_uses_guidance(container) {
408 params.retain(|spec| spec.key != "guidance");
409 }
410 let mut model = IdentifiedModel::new(
411 ModelFormat::Diffusers,
412 Some(profile.modality),
413 profile.capabilities,
414 ExecutionMode::Job,
415 );
416 model.params = params;
417 model.pipeline_class = pipeline_class;
418 model
419}
420
421fn scheduler_facts(container: &Path) -> Option<SchedulerFacts> {
423 let path = container.join("scheduler").join("scheduler_config.json");
424 let bytes = std::fs::read(path).ok()?;
425 let JsonValue::Object(config) = serde_json::from_slice::<JsonValue>(&bytes).ok()? else {
426 return None;
427 };
428 Some(SchedulerFacts::new(
429 config
430 .get("_class_name")
431 .and_then(JsonValue::as_str)
432 .map(str::to_owned),
433 config
434 .get("timestep_spacing")
435 .and_then(JsonValue::as_str)
436 .map(str::to_owned),
437 ))
438}
439
440fn flux_uses_guidance(container: &Path) -> bool {
443 let path = container.join("transformer").join("config.json");
444 let Ok(bytes) = std::fs::read(path) else {
445 return false;
446 };
447 let Ok(JsonValue::Object(config)) = serde_json::from_slice::<JsonValue>(&bytes) else {
448 return false;
449 };
450 config
451 .get("guidance_embeds")
452 .and_then(JsonValue::as_bool)
453 .unwrap_or(false)
454}
455
456fn param(key: &str, param_type: ParamType, range: Option<Vec<JsonValue>>) -> ParamSpec {
457 ParamSpec {
458 key: key.to_owned(),
459 param_type,
460 default_value: None,
461 range,
462 values: None,
463 }
464}
465
466fn builtin_params() -> Vec<ParamSpec> {
467 vec![
468 param(
469 "temperature",
470 ParamType::Float,
471 Some(vec![JsonValue::Double(0.0), JsonValue::Double(2.0)]),
472 ),
473 param(
474 "top_p",
475 ParamType::Float,
476 Some(vec![JsonValue::Double(0.0), JsonValue::Double(1.0)]),
477 ),
478 param(
479 "top_k",
480 ParamType::Int,
481 Some(vec![JsonValue::Int(0), JsonValue::Int(100)]),
482 ),
483 param(
484 "max_tokens",
485 ParamType::Int,
486 Some(vec![JsonValue::Int(1), JsonValue::Int(4096)]),
487 ),
488 param("seed", ParamType::Int, None),
489 ]
490}
491
492fn endpoint_params() -> Vec<ParamSpec> {
493 vec![
494 param(
495 "temperature",
496 ParamType::Float,
497 Some(vec![JsonValue::Double(0.0), JsonValue::Double(2.0)]),
498 ),
499 param(
500 "top_p",
501 ParamType::Float,
502 Some(vec![JsonValue::Double(0.0), JsonValue::Double(1.0)]),
503 ),
504 param(
505 "max_tokens",
506 ParamType::Int,
507 Some(vec![JsonValue::Int(1), JsonValue::Int(32768)]),
508 ),
509 param("stop", ParamType::String, None),
510 param("seed", ParamType::Int, None),
511 param(
512 "frequency_penalty",
513 ParamType::Float,
514 Some(vec![JsonValue::Double(-2.0), JsonValue::Double(2.0)]),
515 ),
516 param(
517 "presence_penalty",
518 ParamType::Float,
519 Some(vec![JsonValue::Double(-2.0), JsonValue::Double(2.0)]),
520 ),
521 ]
522}