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::records::{
11 Capability, ExecutionMode, JsonValue, Modality, ModelRecord, ParamSpec, ParamType, RunTier,
12 RuntimeId, SourceKind,
13};
14use crate::resolution::format::ModelFormat;
15use crate::resolution::format::{gguf_architecture_profile, ollama_profile};
16use crate::resolution::gguf::{gguf_facts, has_ggml_magic, has_gguf_magic};
17use crate::resolution::pipelines::{
18 PipelineFamilyRegistry, SchedulerFacts, diffusers_pipeline_class,
19};
20use crate::resolution::safetensors::safetensors_format;
21
22#[derive(Debug, Clone, PartialEq)]
26pub struct IdentifiedModel {
27 pub format: ModelFormat,
29 pub modality: Option<Modality>,
31 pub capabilities: Vec<Capability>,
33 pub execution: ExecutionMode,
35 pub params: Vec<ParamSpec>,
37 pub pipeline_class: Option<String>,
39 pub context_length: Option<i64>,
41 pub has_chat_template: Option<bool>,
43}
44
45impl IdentifiedModel {
46 pub fn new(
48 format: ModelFormat,
49 modality: Option<Modality>,
50 capabilities: Vec<Capability>,
51 execution: ExecutionMode,
52 ) -> Self {
53 Self {
54 format,
55 modality,
56 capabilities,
57 execution,
58 params: Vec::new(),
59 pipeline_class: None,
60 context_length: None,
61 has_chat_template: None,
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
70pub struct RuntimeBid {
71 pub tier: RunTier,
73 pub preference: i64,
76 pub alternatives: Vec<RuntimeId>,
78}
79
80impl RuntimeBid {
81 pub fn new(tier: RunTier, preference: i64) -> Self {
83 Self {
84 tier,
85 preference,
86 alternatives: Vec::new(),
87 }
88 }
89
90 pub fn with_alternatives(tier: RunTier, preference: i64, alternatives: Vec<RuntimeId>) -> Self {
92 Self {
93 tier,
94 preference,
95 alternatives,
96 }
97 }
98}
99
100pub fn identify(record: &ModelRecord) -> IdentifiedModel {
107 let kind = &record.source.kind;
108 if *kind == SourceKind::builtin() {
109 return chat_model(ModelFormat::Builtin, builtin_params());
110 }
111 if *kind == SourceKind::endpoint() {
112 return chat_model(ModelFormat::Endpoint, endpoint_params());
113 }
114 if *kind == SourceKind::ollama() {
115 let profile = ollama_profile(
116 manifest_has_projector_layer(&record.source.path),
117 record.primary_weight_path.as_deref(),
118 );
119 return IdentifiedModel::new(
120 ModelFormat::OllamaStore,
121 Some(profile.modality),
122 profile.capabilities,
123 profile.execution,
124 );
125 }
126
127 let base = Path::new(&record.source.path);
128 let container = container_url(base, record);
129 let extension = base
130 .extension()
131 .and_then(|ext| ext.to_str())
132 .map(str::to_ascii_lowercase);
133
134 if extension.as_deref() == Some("bin") && has_ggml_magic(base) {
135 return IdentifiedModel::new(
136 ModelFormat::GgmlBin,
137 Some(Modality::audio()),
138 vec![Capability::transcribe()],
139 ExecutionMode::Stream,
140 );
141 }
142
143 if extension.as_deref() == Some("gguf") || has_gguf_magic(base) {
144 return identify_gguf(base);
145 }
146
147 let model_index = container.join("model_index.json");
148 if model_index.exists() {
149 return identify_diffusers(&model_index, &container, record);
150 }
151
152 let config = container.join("config.json");
153 let hint = from_config_json(&config);
154 if let Some(format) = safetensors_format(&container, &config) {
155 return identify_safetensors(format, hint.as_ref(), &container);
156 }
157 match hint {
158 Some(hint) => {
159 let mut model = IdentifiedModel::new(
160 ModelFormat::Unknown,
161 hint.modality,
162 hint.capabilities,
163 hint.execution,
164 );
165 model.context_length = hint.context_length;
166 model
167 }
168 None => IdentifiedModel::new(ModelFormat::Unknown, None, Vec::new(), ExecutionMode::Sync),
169 }
170}
171
172fn identify_gguf(base: &Path) -> IdentifiedModel {
173 let name = base
174 .file_name()
175 .and_then(|name| name.to_str())
176 .unwrap_or_default();
177 if is_mmproj_name(name) {
178 return IdentifiedModel::new(
180 ModelFormat::Gguf,
181 Some(Modality::vision()),
182 Vec::new(),
183 ExecutionMode::Sync,
184 );
185 }
186 let facts = gguf_facts(base);
187 if let Some(architecture) = facts
188 .as_ref()
189 .and_then(|facts| facts.architecture.as_deref())
190 && let Some(profile) = gguf_architecture_profile(architecture)
191 {
192 let mut model = IdentifiedModel::new(
193 ModelFormat::Gguf,
194 Some(profile.modality),
195 profile.capabilities,
196 profile.execution,
197 );
198 model.context_length = facts.as_ref().and_then(|facts| facts.context_length);
199 model.has_chat_template = facts.as_ref().map(|facts| facts.has_chat_template);
200 return model;
201 }
202 let capabilities = if has_mmproj_companion(base) {
203 vec![
204 Capability::chat(),
205 Capability::complete(),
206 Capability::see(),
207 ]
208 } else {
209 vec![Capability::chat(), Capability::complete()]
210 };
211 let mut model = IdentifiedModel::new(
212 ModelFormat::Gguf,
213 Some(Modality::text()),
214 capabilities,
215 ExecutionMode::Stream,
216 );
217 model.context_length = facts.as_ref().and_then(|facts| facts.context_length);
218 model.has_chat_template = facts.as_ref().map(|facts| facts.has_chat_template);
219 model
220}
221
222fn identify_safetensors(
223 format: ModelFormat,
224 hint: Option<&Hint>,
225 container: &Path,
226) -> IdentifiedModel {
227 let hint_modality = hint.and_then(|hint| hint.modality.clone());
228 let text = Some(Modality::text());
229 if (hint_modality.is_none() || hint_modality == text)
230 && has_sentence_transformers_layout(container)
231 {
232 let mut model = IdentifiedModel::new(
233 format,
234 Some(Modality::embedding()),
235 vec![Capability::embed()],
236 ExecutionMode::Stream,
237 );
238 model.context_length = hint.and_then(|hint| hint.context_length);
239 return model;
240 }
241 let mut model = IdentifiedModel::new(
242 format,
243 hint.and_then(|hint| hint.modality.clone()),
244 hint.map(|hint| hint.capabilities.clone())
245 .unwrap_or_default(),
246 hint.map_or(ExecutionMode::Sync, |hint| hint.execution),
247 );
248 model.context_length = hint.and_then(|hint| hint.context_length);
249 model
250}
251
252fn chat_model(format: ModelFormat, params: Vec<ParamSpec>) -> IdentifiedModel {
253 let mut model = IdentifiedModel::new(
254 format,
255 Some(Modality::text()),
256 vec![Capability::chat(), Capability::complete()],
257 ExecutionMode::Stream,
258 );
259 model.params = params;
260 model
261}
262
263fn container_url(base: &Path, record: &ModelRecord) -> PathBuf {
266 if record.source.kind == SourceKind::huggingface_cache()
267 && let Some(reference) = &record.source.reference
268 {
269 let snapshot = base.join("snapshots").join(reference);
270 if snapshot.exists() {
271 return snapshot;
272 }
273 }
274 base.to_path_buf()
275}
276
277fn manifest_has_projector_layer(path: &str) -> bool {
279 let Ok(bytes) = std::fs::read(path) else {
280 return false;
281 };
282 let Ok(JsonValue::Object(object)) = serde_json::from_slice::<JsonValue>(&bytes) else {
283 return false;
284 };
285 let Some(JsonValue::Array(layers)) = object.get("layers") else {
286 return false;
287 };
288 layers.iter().any(|layer| {
289 layer
290 .as_object()
291 .and_then(|fields| fields.get("mediaType"))
292 .and_then(JsonValue::as_str)
293 .is_some_and(|media| media.ends_with(".projector"))
294 })
295}
296
297fn has_mmproj_companion(base: &Path) -> bool {
299 let Some(directory) = base.parent() else {
300 return false;
301 };
302 let base_name = base.file_name().and_then(|name| name.to_str());
303 let Ok(entries) = std::fs::read_dir(directory) else {
304 return false;
305 };
306 entries.flatten().any(|entry| {
307 let path = entry.path();
308 let name = path.file_name().and_then(|name| name.to_str());
309 name != base_name
310 && name.is_some_and(|name| !name.starts_with('.') && is_mmproj_name(name))
312 && path
313 .extension()
314 .and_then(|ext| ext.to_str())
315 .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
316 })
317}
318
319fn has_sentence_transformers_layout(container: &Path) -> bool {
320 const MARKERS: [&str; 2] = ["config_sentence_transformers.json", "1_Pooling"];
321 let Ok(entries) = std::fs::read_dir(container) else {
322 return false;
323 };
324 entries.flatten().any(|entry| {
325 entry
326 .file_name()
327 .to_str()
328 .is_some_and(|name| MARKERS.contains(&name))
329 })
330}
331
332fn identify_diffusers(
337 model_index: &Path,
338 container: &Path,
339 record: &ModelRecord,
340) -> IdentifiedModel {
341 let pipeline_class = diffusers_pipeline_class(model_index);
342 let scheduler = scheduler_facts(container);
343 let repo_hint = record.source.repo.as_deref().unwrap_or(&record.name);
344 let profile = pipeline_class.as_deref().and_then(|class| {
345 PipelineFamilyRegistry::shared().profile(class, scheduler.as_ref(), Some(repo_hint))
346 });
347 let Some(profile) = profile else {
348 let mut model =
349 IdentifiedModel::new(ModelFormat::Diffusers, None, Vec::new(), ExecutionMode::Job);
350 model.pipeline_class = pipeline_class;
351 return model;
352 };
353 let mut params = profile.params;
354 if pipeline_class.as_deref() == Some("FluxPipeline") && !flux_uses_guidance(container) {
357 params.retain(|spec| spec.key != "guidance");
358 }
359 let mut model = IdentifiedModel::new(
360 ModelFormat::Diffusers,
361 Some(profile.modality),
362 profile.capabilities,
363 ExecutionMode::Job,
364 );
365 model.params = params;
366 model.pipeline_class = pipeline_class;
367 model
368}
369
370fn scheduler_facts(container: &Path) -> Option<SchedulerFacts> {
372 let path = container.join("scheduler").join("scheduler_config.json");
373 let bytes = std::fs::read(path).ok()?;
374 let JsonValue::Object(config) = serde_json::from_slice::<JsonValue>(&bytes).ok()? else {
375 return None;
376 };
377 Some(SchedulerFacts::new(
378 config
379 .get("_class_name")
380 .and_then(JsonValue::as_str)
381 .map(str::to_owned),
382 config
383 .get("timestep_spacing")
384 .and_then(JsonValue::as_str)
385 .map(str::to_owned),
386 ))
387}
388
389fn flux_uses_guidance(container: &Path) -> bool {
392 let path = container.join("transformer").join("config.json");
393 let Ok(bytes) = std::fs::read(path) else {
394 return false;
395 };
396 let Ok(JsonValue::Object(config)) = serde_json::from_slice::<JsonValue>(&bytes) else {
397 return false;
398 };
399 config
400 .get("guidance_embeds")
401 .and_then(JsonValue::as_bool)
402 .unwrap_or(false)
403}
404
405fn param(key: &str, param_type: ParamType, range: Option<Vec<JsonValue>>) -> ParamSpec {
406 ParamSpec {
407 key: key.to_owned(),
408 param_type,
409 default_value: None,
410 range,
411 values: None,
412 }
413}
414
415fn builtin_params() -> Vec<ParamSpec> {
416 vec![
417 param(
418 "temperature",
419 ParamType::Float,
420 Some(vec![JsonValue::Double(0.0), JsonValue::Double(2.0)]),
421 ),
422 param(
423 "top_p",
424 ParamType::Float,
425 Some(vec![JsonValue::Double(0.0), JsonValue::Double(1.0)]),
426 ),
427 param(
428 "top_k",
429 ParamType::Int,
430 Some(vec![JsonValue::Int(0), JsonValue::Int(100)]),
431 ),
432 param(
433 "max_tokens",
434 ParamType::Int,
435 Some(vec![JsonValue::Int(1), JsonValue::Int(4096)]),
436 ),
437 param("seed", ParamType::Int, None),
438 ]
439}
440
441fn endpoint_params() -> Vec<ParamSpec> {
442 vec![
443 param(
444 "temperature",
445 ParamType::Float,
446 Some(vec![JsonValue::Double(0.0), JsonValue::Double(2.0)]),
447 ),
448 param(
449 "top_p",
450 ParamType::Float,
451 Some(vec![JsonValue::Double(0.0), JsonValue::Double(1.0)]),
452 ),
453 param(
454 "max_tokens",
455 ParamType::Int,
456 Some(vec![JsonValue::Int(1), JsonValue::Int(32768)]),
457 ),
458 param("stop", ParamType::String, None),
459 param("seed", ParamType::Int, None),
460 param(
461 "frequency_penalty",
462 ParamType::Float,
463 Some(vec![JsonValue::Double(-2.0), JsonValue::Double(2.0)]),
464 ),
465 param(
466 "presence_penalty",
467 ParamType::Float,
468 Some(vec![JsonValue::Double(-2.0), JsonValue::Double(2.0)]),
469 ),
470 ]
471}