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::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/// What identification determined about a model: its format and the modality,
25/// capabilities, execution shape, parameter schema, and context/template facts
26/// implied by it.
27#[derive(Debug, Clone, PartialEq)]
28pub struct IdentifiedModel {
29    /// The recognized format.
30    pub format: ModelFormat,
31    /// The modality, if determined.
32    pub modality: Option<Modality>,
33    /// The capabilities the model can serve.
34    pub capabilities: Vec<Capability>,
35    /// How the model executes.
36    pub execution: ExecutionMode,
37    /// The parameter schema for the model.
38    pub params: Vec<ParamSpec>,
39    /// The diffusers pipeline class, if any.
40    pub pipeline_class: Option<String>,
41    /// A context-window hint.
42    pub context_length: Option<i64>,
43    /// Whether the model ships a chat template.
44    pub has_chat_template: Option<bool>,
45    /// The quantization the weights carry, as their format names it.
46    pub quantization: Option<String>,
47}
48
49impl IdentifiedModel {
50    /// An identification with just the core fields; the rest default to empty.
51    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/// A runtime adapter's offer to serve a model: how well it runs (the tier), a
72/// ranking preference (lower wins), and the other runtimes that could also serve
73/// it (recorded as alternatives on the resolved record).
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub struct RuntimeBid {
76    /// How the runtime would run the model.
77    pub tier: RunTier,
78    /// The ranking preference: a lower value wins. The `tier` is not part of the
79    /// ordering (it is recorded on the winner, not compared).
80    pub preference: i64,
81    /// Other runtimes that could serve the model.
82    pub alternatives: Vec<RuntimeId>,
83}
84
85impl RuntimeBid {
86    /// A bid at `tier`/`preference` with no alternatives.
87    pub fn new(tier: RunTier, preference: i64) -> Self {
88        Self {
89            tier,
90            preference,
91            alternatives: Vec::new(),
92        }
93    }
94
95    /// A bid carrying the runtimes that could also serve the model.
96    pub fn with_alternatives(tier: RunTier, preference: i64, alternatives: Vec<RuntimeId>) -> Self {
97        Self {
98            tier,
99            preference,
100            alternatives,
101        }
102    }
103}
104
105/// Identify what a `record` is from its source kind and on-disk files: a fixed
106/// profile for builtin/endpoint/ollama models, else the GGUF/GGML header, a
107/// diffusers `model_index.json`, a `config.json`+safetensors layout, or the
108/// GGUF weights inside the container when the record names a directory, which
109/// is the shape a Hugging Face cache repo has.
110///
111/// `record.source.path` is taken as-is (callers pass an absolute path — discovery
112/// does); a leading `~` is not expanded.
113pub 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        // The blob a manifest points at is a GGUF, so its header says what the
123        // manifest does not: the architecture and the quantization.
124        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    // A repo whose weights are GGUF names a directory, so the file the header
175    // is read from is inside it. Placed after the diffusers and safetensors
176    // layouts so a directory that resolves as one keeps doing so, and before
177    // the bare config hint below, which it deliberately outranks: a header
178    // read from the weights says more than a sibling config file.
179    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
199/// The GGUF file a model held in a directory is served from, when it is one,
200/// and whether a projector sits with it. Which file that is comes from
201/// [`primary_of`], the rule the store scanners pick a primary weight by, so the
202/// header read here belongs to the file a server will load. Weights kept in a
203/// subdirectory, as a repo with one folder per quantization keeps them, are
204/// reached the same way, and so is a projector left at the root beside them.
205///
206/// The file is named through the container rather than through `primary`,
207/// which a Hugging Face record resolves to a blob whose name carries nothing:
208/// the projector checks read both the name and its neighbours.
209///
210/// `primary` refuses the whole container when it is not itself a GGUF, missing
211/// included: of the formats this branch can answer, what the runtime would
212/// load has the last word.
213///
214/// An architecture [`gguf_architecture_profile`] does not know reads as a text
215/// chat model, which is the answer the same bytes get as a loose file.
216fn 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
227/// What a GGUF is, from its header. `has_projector` says whether a multimodal
228/// projector accompanies it, which is what makes sight real: an architecture
229/// that can see cannot without one.
230fn 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        // A CLIP/mmproj projector: vision-only, no directly-served capability.
237        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            // The architecture can see, but no image encoder came with these
253            // weights, so nothing here can read a picture. Sight is the
254            // pairing, not the architecture.
255            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
315/// Copy onto `model` what a GGUF header said about it. Every branch that reads
316/// a header ends this way, so the set of facts a header carries is named once.
317fn 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
323/// The same for what a `config.json` said, which is less: a config names no
324/// chat template.
325fn 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
341/// The snapshot directory for a Hugging Face cache record (its `ref` under
342/// `snapshots/`, if present), else the base path itself.
343fn 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
355/// Whether an Ollama manifest at `path` declares a `.projector` (vision) layer.
356fn 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
375/// Whether a sibling `mmproj` GGUF (a vision projector) sits beside `base`.
376fn 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            // Skip hidden files.
389            && 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
410/// Identify a diffusers bundle from its `model_index.json` and the pipeline-family
411/// registry: the `_class_name` selects a family whose modality/capabilities/params
412/// (refined by the scheduler + repo name) become the identification. An unknown or
413/// absent class falls back to a bare `Diffusers` job carrying just the class name.
414fn 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    // FLUX schnell/dev differ: a distilled model that ignores guidance drops the
433    // guidance parameter entirely rather than exposing a dead knob.
434    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
448/// The scheduler facts from `scheduler/scheduler_config.json`, if the file parses.
449fn 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
467/// Whether a FLUX pipeline's transformer declares `guidance_embeds` (a guidance-
468/// distilled model), read from `transformer/config.json`.
469fn 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}