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::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/// What identification determined about a model: its format and the modality,
24/// capabilities, execution shape, parameter schema, and context/template facts
25/// implied by it.
26#[derive(Debug, Clone, PartialEq)]
27pub struct IdentifiedModel {
28    /// The recognized format.
29    pub format: ModelFormat,
30    /// The modality, if determined.
31    pub modality: Option<Modality>,
32    /// The capabilities the model can serve.
33    pub capabilities: Vec<Capability>,
34    /// How the model executes.
35    pub execution: ExecutionMode,
36    /// The parameter schema for the model.
37    pub params: Vec<ParamSpec>,
38    /// The diffusers pipeline class, if any.
39    pub pipeline_class: Option<String>,
40    /// A context-window hint.
41    pub context_length: Option<i64>,
42    /// Whether the model ships a chat template.
43    pub has_chat_template: Option<bool>,
44}
45
46impl IdentifiedModel {
47    /// An identification with just the core fields; the rest default to empty.
48    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/// A runtime adapter's offer to serve a model: how well it runs (the tier), a
68/// ranking preference (lower wins), and the other runtimes that could also serve
69/// it (recorded as alternatives on the resolved record).
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
71pub struct RuntimeBid {
72    /// How the runtime would run the model.
73    pub tier: RunTier,
74    /// The ranking preference: a lower value wins. The `tier` is not part of the
75    /// ordering (it is recorded on the winner, not compared).
76    pub preference: i64,
77    /// Other runtimes that could serve the model.
78    pub alternatives: Vec<RuntimeId>,
79}
80
81impl RuntimeBid {
82    /// A bid at `tier`/`preference` with no alternatives.
83    pub fn new(tier: RunTier, preference: i64) -> Self {
84        Self {
85            tier,
86            preference,
87            alternatives: Vec::new(),
88        }
89    }
90
91    /// A bid carrying the runtimes that could also serve the model.
92    pub fn with_alternatives(tier: RunTier, preference: i64, alternatives: Vec<RuntimeId>) -> Self {
93        Self {
94            tier,
95            preference,
96            alternatives,
97        }
98    }
99}
100
101/// Identify what a `record` is from its source kind and on-disk files: a fixed
102/// profile for builtin/endpoint/ollama models, else the GGUF/GGML header, a
103/// diffusers `model_index.json`, a `config.json`+safetensors layout, or the
104/// GGUF weights inside the container when the record names a directory, which
105/// is the shape a Hugging Face cache repo has.
106///
107/// `record.source.path` is taken as-is (callers pass an absolute path — discovery
108/// does); a leading `~` is not expanded.
109pub 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    // A repo whose weights are GGUF names a directory, so the file the header
161    // is read from is inside it. Placed after the diffusers and safetensors
162    // layouts so a directory that resolves as one keeps doing so, and before
163    // the bare config hint below, which it deliberately outranks: a header
164    // read from the weights says more than a sibling config file.
165    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
185/// The GGUF file a model held in a directory is served from, when it is one,
186/// and whether a projector sits with it. Which file that is comes from
187/// [`primary_of`], the rule the store scanners pick a primary weight by, so the
188/// header read here belongs to the file a server will load. Weights kept in a
189/// subdirectory, as a repo with one folder per quantization keeps them, are
190/// reached the same way, and so is a projector left at the root beside them.
191///
192/// The file is named through the container rather than through `primary`,
193/// which a Hugging Face record resolves to a blob whose name carries nothing:
194/// the projector checks read both the name and its neighbours.
195///
196/// `primary` refuses the whole container when it is not itself a GGUF, missing
197/// included: of the formats this branch can answer, what the runtime would
198/// load has the last word.
199///
200/// An architecture [`gguf_architecture_profile`] does not know reads as a text
201/// chat model, which is the answer the same bytes get as a loose file.
202fn 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
213/// What a GGUF is, from its header. `has_projector` says whether a multimodal
214/// projector accompanies it, which is what makes sight real: an architecture
215/// that can see cannot without one.
216fn 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        // A CLIP/mmproj projector: vision-only, no directly-served capability.
223        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            // The architecture can see, but no image encoder came with these
239            // weights, so nothing here can read a picture. Sight is the
240            // pairing, not the architecture.
241            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
314/// The snapshot directory for a Hugging Face cache record (its `ref` under
315/// `snapshots/`, if present), else the base path itself.
316fn 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
328/// Whether an Ollama manifest at `path` declares a `.projector` (vision) layer.
329fn 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
348/// Whether a sibling `mmproj` GGUF (a vision projector) sits beside `base`.
349fn 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            // Skip hidden files.
362            && 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
383/// Identify a diffusers bundle from its `model_index.json` and the pipeline-family
384/// registry: the `_class_name` selects a family whose modality/capabilities/params
385/// (refined by the scheduler + repo name) become the identification. An unknown or
386/// absent class falls back to a bare `Diffusers` job carrying just the class name.
387fn 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    // FLUX schnell/dev differ: a distilled model that ignores guidance drops the
406    // guidance parameter entirely rather than exposing a dead knob.
407    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
421/// The scheduler facts from `scheduler/scheduler_config.json`, if the file parses.
422fn 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
440/// Whether a FLUX pipeline's transformer declares `guidance_embeds` (a guidance-
441/// distilled model), read from `transformer/config.json`.
442fn 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}