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