Skip to main content

kernel/resolution/
identity.rs

1//! The identity and bid foundation types: what `Identification::identify`
2//! produces about a model ([`IdentifiedModel`]) and what a runtime adapter
3//! offers to serve it ([`RuntimeBid`]). The `identify` orchestration and the bid
4//! auction build on these.
5
6use 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/// What identification determined about a model: its format and the modality,
23/// capabilities, execution shape, parameter schema, and context/template facts
24/// implied by it.
25#[derive(Debug, Clone, PartialEq)]
26pub struct IdentifiedModel {
27    /// The recognized format.
28    pub format: ModelFormat,
29    /// The modality, if determined.
30    pub modality: Option<Modality>,
31    /// The capabilities the model can serve.
32    pub capabilities: Vec<Capability>,
33    /// How the model executes.
34    pub execution: ExecutionMode,
35    /// The parameter schema for the model.
36    pub params: Vec<ParamSpec>,
37    /// The diffusers pipeline class, if any.
38    pub pipeline_class: Option<String>,
39    /// A context-window hint.
40    pub context_length: Option<i64>,
41    /// Whether the model ships a chat template.
42    pub has_chat_template: Option<bool>,
43}
44
45impl IdentifiedModel {
46    /// An identification with just the core fields; the rest default to empty.
47    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/// A runtime adapter's offer to serve a model: how well it runs (the tier), a
67/// ranking preference (lower wins), and the other runtimes that could also serve
68/// it (recorded as alternatives on the resolved record).
69#[derive(Debug, Clone, PartialEq, Eq, Hash)]
70pub struct RuntimeBid {
71    /// How the runtime would run the model.
72    pub tier: RunTier,
73    /// The ranking preference: a lower value wins. The `tier` is not part of the
74    /// ordering (it is recorded on the winner, not compared).
75    pub preference: i64,
76    /// Other runtimes that could serve the model.
77    pub alternatives: Vec<RuntimeId>,
78}
79
80impl RuntimeBid {
81    /// A bid at `tier`/`preference` with no alternatives.
82    pub fn new(tier: RunTier, preference: i64) -> Self {
83        Self {
84            tier,
85            preference,
86            alternatives: Vec::new(),
87        }
88    }
89
90    /// A bid carrying the runtimes that could also serve the model.
91    pub fn with_alternatives(tier: RunTier, preference: i64, alternatives: Vec<RuntimeId>) -> Self {
92        Self {
93            tier,
94            preference,
95            alternatives,
96        }
97    }
98}
99
100/// Identify what a `record` is from its source kind and on-disk files: a fixed
101/// profile for builtin/endpoint/ollama models, else the GGUF/GGML header, a
102/// diffusers `model_index.json`, or a `config.json`+safetensors layout.
103///
104/// `record.source.path` is taken as-is (callers pass an absolute path — discovery
105/// does); a leading `~` is not expanded.
106pub 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        // A CLIP/mmproj projector: vision-only, no directly-served capability.
179        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
263/// The snapshot directory for a Hugging Face cache record (its `ref` under
264/// `snapshots/`, if present), else the base path itself.
265fn 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
277/// Whether an Ollama manifest at `path` declares a `.projector` (vision) layer.
278fn 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
297/// Whether a sibling `mmproj` GGUF (a vision projector) sits beside `base`.
298fn 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            // Skip hidden files.
311            && 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
332/// Identify a diffusers bundle from its `model_index.json` and the pipeline-family
333/// registry: the `_class_name` selects a family whose modality/capabilities/params
334/// (refined by the scheduler + repo name) become the identification. An unknown or
335/// absent class falls back to a bare `Diffusers` job carrying just the class name.
336fn 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    // FLUX schnell/dev differ: a distilled model that ignores guidance drops the
355    // guidance parameter entirely rather than exposing a dead knob.
356    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
370/// The scheduler facts from `scheduler/scheduler_config.json`, if the file parses.
371fn 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
389/// Whether a FLUX pipeline's transformer declares `guidance_embeds` (a guidance-
390/// distilled model), read from `transformer/config.json`.
391fn 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}