Skip to main content

model_package/
prepare.rs

1use std::collections::HashMap;
2
3use anyhow::{Context, Result};
4use futures::StreamExt;
5use hf_hub::HFClient;
6use hf_hub::repository::RepoTreeEntry;
7use model_ref::{
8    gguf_matches_quant_selector, normalize_gguf_distribution_id, quant_selector_from_gguf_file,
9    split_gguf_shard_info,
10};
11use serde::Serialize;
12
13use crate::jobs::{CpuJobPlan, JobSpec, JobVolume};
14use crate::permissions::PermissionCheck;
15
16/// Parameters for a model-package job.
17pub struct PrepareParams {
18    pub source_repo: String,
19    pub source_revision: Option<String>,
20    pub quant: Option<String>,
21    pub target: Option<String>,
22    pub model_id: Option<String>,
23    pub flavor: String,
24    pub timeout_seconds: u64,
25    pub mesh_llm_ref: String,
26    pub experimental: bool,
27    pub hf_token: Option<String>,
28}
29
30/// A fully resolved model-package job, ready to submit.
31pub struct PrepareJob {
32    pub source_repo: String,
33    pub source_revision: String,
34    pub source_file: String,
35    pub projectors: Vec<DiscoveredProjector>,
36    pub target_repo: String,
37    pub model_id: String,
38    pub namespace: String,
39    pub catalog_create_pr: bool,
40    pub experimental: bool,
41    pub job_plan: CpuJobPlan,
42    pub spec: JobSpec,
43}
44
45/// A discovered quant variant in a HF model repo.
46#[derive(Debug, Clone, Serialize)]
47pub struct DiscoveredQuant {
48    /// The quant selector name (e.g. "Q4_K_M", "UD-Q4_K_XL").
49    pub name: String,
50    /// Number of GGUF files (shards) for this quant.
51    pub shard_count: usize,
52    /// Total size in bytes across all shards.
53    pub total_bytes: u64,
54    /// The first shard file path (or single file path).
55    pub first_file: String,
56}
57
58/// A multimodal projector sidecar discovered in a HF model repo.
59#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
60pub struct DiscoveredProjector {
61    /// Repo-relative projector path.
62    pub path: String,
63    /// Projector size in bytes.
64    pub total_bytes: u64,
65}
66
67/// GGUF artifacts discovered in a HF model repo, separated by runtime role.
68#[derive(Debug, Clone, Default)]
69pub struct RepoGgufInventory {
70    pub quants: Vec<DiscoveredQuant>,
71    pub projectors: Vec<DiscoveredProjector>,
72}
73
74/// List all available GGUF quant variants in a HF model repo.
75pub async fn list_quants(client: &HFClient, repo: &str) -> Result<Vec<DiscoveredQuant>> {
76    Ok(list_inventory(client, repo, None).await?.quants)
77}
78
79/// List model GGUF quants and multimodal projectors at an optional repo revision.
80pub async fn list_inventory(
81    client: &HFClient,
82    repo: &str,
83    revision: Option<&str>,
84) -> Result<RepoGgufInventory> {
85    let (owner, name) = parse_repo(repo)?;
86    let hf_repo = client.model(&owner, &name);
87
88    let stream = hf_repo
89        .list_tree()
90        .maybe_revision(revision.map(str::to_string))
91        .recursive(true)
92        .send()
93        .context("list repo tree")?;
94
95    futures::pin_mut!(stream);
96
97    // Collect all GGUF files with their sizes.
98    let mut gguf_files: Vec<(String, u64)> = Vec::new();
99    while let Some(entry) = stream.next().await {
100        let entry = entry.context("read repo tree entry")?;
101        if let RepoTreeEntry::File { path, size, .. } = entry
102            && path.ends_with(".gguf")
103        {
104            gguf_files.push((path, size));
105        }
106    }
107
108    Ok(discover_inventory_from_gguf_files(gguf_files))
109}
110
111/// Separate model GGUF distributions from multimodal projector sidecars.
112pub fn discover_inventory_from_gguf_files(gguf_files: Vec<(String, u64)>) -> RepoGgufInventory {
113    let (projector_files, model_files): (Vec<_>, Vec<_>) = gguf_files
114        .into_iter()
115        .partition(|(path, _)| is_projector_path(path));
116    let mut projectors = projector_files
117        .into_iter()
118        .map(|(path, total_bytes)| DiscoveredProjector { path, total_bytes })
119        .collect::<Vec<_>>();
120    projectors.sort_by(|a, b| a.path.cmp(&b.path));
121    RepoGgufInventory {
122        quants: discover_quants_from_gguf_files(model_files),
123        projectors,
124    }
125}
126
127fn is_projector_path(path: &str) -> bool {
128    std::path::Path::new(path)
129        .file_name()
130        .and_then(|name| name.to_str())
131        .is_some_and(|name| {
132            let name = name.to_ascii_lowercase();
133            name.starts_with("mmproj") && name.ends_with(".gguf")
134        })
135}
136
137/// Group GGUF files into quant variants.
138pub fn discover_quants_from_gguf_files(gguf_files: Vec<(String, u64)>) -> Vec<DiscoveredQuant> {
139    let mut quant_map: HashMap<String, Vec<(String, u64)>> = HashMap::new();
140    for (path, size) in gguf_files {
141        if let Some(selector) = quant_selector_from_gguf_file(&path) {
142            quant_map.entry(selector).or_default().push((path, size));
143        } else if let Some(dist_id) = normalize_gguf_distribution_id(&path) {
144            // Fallback: use the distribution ID as the selector.
145            quant_map.entry(dist_id).or_default().push((path, size));
146        }
147    }
148
149    let mut quants: Vec<DiscoveredQuant> = quant_map
150        .into_iter()
151        .map(|(name, mut files)| {
152            // Sort so shard -00001 comes first.
153            files.sort_by(|a, b| a.0.cmp(&b.0));
154            let total_bytes = files.iter().map(|(_, size)| size).sum();
155            let shard_count = files.len();
156            let first_file = files[0].0.clone();
157            DiscoveredQuant {
158                name,
159                shard_count,
160                total_bytes,
161                first_file,
162            }
163        })
164        .collect();
165
166    // Sort by name for stable output.
167    quants.sort_by(|a, b| a.name.cmp(&b.name));
168    quants
169}
170
171/// Resolve source files, permissions, target repo, and build the job spec.
172pub async fn resolve(
173    client: &HFClient,
174    params: PrepareParams,
175    permissions: &PermissionCheck,
176) -> Result<PrepareJob> {
177    let quant = params
178        .quant
179        .as_deref()
180        .context("--quant is required when submitting a job")?;
181
182    let (owner, name) = parse_repo(&params.source_repo)?;
183    let hf_repo = client.model(&owner, &name);
184    let requested_revision = params.source_revision.as_deref().unwrap_or("main");
185    let source_info = hf_repo
186        .info()
187        .revision(requested_revision.to_string())
188        .send()
189        .await
190        .with_context(|| {
191            format!(
192                "resolve source revision {}@{requested_revision}",
193                params.source_repo
194            )
195        })?;
196    let source_revision = source_info
197        .sha
198        .context("source repo info did not include a commit SHA")?;
199    let source_pipeline_tag = source_info
200        .pipeline_tag
201        .unwrap_or_else(|| "text-generation".to_string());
202    let inventory = list_inventory(client, &params.source_repo, Some(&source_revision)).await?;
203    let quants = inventory.quants;
204
205    if quants.is_empty() {
206        anyhow::bail!("No GGUF files found in {}", params.source_repo);
207    }
208
209    // Find matching quant.
210    let matched = quants
211        .iter()
212        .find(|q| q.name.eq_ignore_ascii_case(quant))
213        .or_else(|| {
214            // Fall back to gguf_matches_quant_selector on the first file.
215            quants
216                .iter()
217                .find(|q| gguf_matches_quant_selector(&q.first_file, quant))
218        })
219        .with_context(|| {
220            let available: Vec<&str> = quants.iter().map(|q| q.name.as_str()).collect();
221            format!(
222                "No quant matching '{}' in {}.\nAvailable: {}",
223                quant,
224                params.source_repo,
225                available.join(", ")
226            )
227        })?;
228
229    // For sharded models, ensure we have the first shard.
230    let source_file = if let Some(shard) = split_gguf_shard_info(&matched.first_file) {
231        // Verify it's shard 00001.
232        if shard.part != "00001" {
233            // Reconstruct the -00001- path.
234            matched
235                .first_file
236                .replace(&format!("-{}-of-", shard.part), "-00001-of-")
237        } else {
238            matched.first_file.clone()
239        }
240    } else {
241        matched.first_file.clone()
242    };
243
244    // Derive distribution ID and target repo.
245    let dist_id =
246        normalize_gguf_distribution_id(&source_file).unwrap_or_else(|| matched.name.clone());
247
248    let target_repo = params
249        .target
250        .unwrap_or_else(|| format!("{}/{}-layers", permissions.namespace, dist_id));
251
252    let model_id = resolve_model_id(
253        params.model_id,
254        &params.source_repo,
255        &source_file,
256        &matched.name,
257    )?;
258
259    let projector_bytes = inventory
260        .projectors
261        .iter()
262        .try_fold(0u64, |total, projector| {
263            total.checked_add(projector.total_bytes)
264        })
265        .context("source GGUF and projector sizes overflowed u64")?;
266    let source_total_bytes = matched
267        .total_bytes
268        .checked_add(projector_bytes)
269        .context("source GGUF and projector sizes overflowed u64")?;
270    let job_plan = crate::jobs::plan_cpu_job(
271        &crate::jobs::hf_endpoint(),
272        &params.flavor,
273        params.timeout_seconds,
274        source_total_bytes,
275    )
276    .await?;
277
278    // Build environment variables.
279    let mut environment = HashMap::new();
280    environment.insert("SOURCE_REPO".into(), params.source_repo.clone());
281    environment.insert("SOURCE_FILE".into(), source_file.clone());
282    environment.insert("SOURCE_QUANT".into(), matched.name.clone());
283    environment.insert("SOURCE_TOTAL_BYTES".into(), source_total_bytes.to_string());
284    environment.insert("TARGET_REPO".into(), target_repo.clone());
285    environment.insert("MODEL_ID".into(), model_id.clone());
286    environment.insert("SOURCE_REVISION".into(), source_revision.clone());
287    environment.insert("SOURCE_PIPELINE_TAG".into(), source_pipeline_tag);
288    if !inventory.projectors.is_empty() {
289        environment.insert(
290            "SOURCE_PROJECTOR_FILES".into(),
291            inventory
292                .projectors
293                .iter()
294                .map(|projector| projector.path.as_str())
295                .collect::<Vec<_>>()
296                .join("\n"),
297        );
298    }
299    environment.insert("MESH_LLM_REF".into(), params.mesh_llm_ref.clone());
300    let catalog_create_pr = params.experimental || permissions.catalog_create_pr;
301    environment.insert(
302        "CATALOG_CREATE_PR".into(),
303        if catalog_create_pr { "true" } else { "false" }.into(),
304    );
305    environment.insert(
306        "PACKAGE_EXPERIMENTAL".into(),
307        if params.experimental { "true" } else { "false" }.into(),
308    );
309
310    // The HF Jobs API passes secrets as env vars inside the container.
311    // Dry runs intentionally omit secrets so users can inspect cost/spec first.
312    let mut secrets = HashMap::new();
313    if let Some(hf_token) = params.hf_token {
314        secrets.insert("HF_TOKEN".into(), hf_token);
315    }
316
317    let volumes = vec![
318        JobVolume {
319            volume_type: "bucket".into(),
320            source: "meshllm/layer-split-output".into(),
321            mount_path: "/bucket".into(),
322            read_only: None,
323            revision: None,
324        },
325        JobVolume {
326            volume_type: "model".into(),
327            source: params.source_repo.clone(),
328            mount_path: "/source".into(),
329            read_only: Some(true),
330            revision: Some(source_revision.clone()),
331        },
332    ];
333
334    let spec = JobSpec {
335        docker_image: "ubuntu:22.04".into(),
336        command: vec!["bash".into(), "/bucket/split-model-job.sh".into()],
337        arguments: vec![],
338        environment,
339        secrets,
340        flavor: job_plan.flavor.clone(),
341        timeout_seconds: job_plan.timeout_seconds,
342        volumes,
343    };
344
345    Ok(PrepareJob {
346        source_repo: params.source_repo,
347        source_revision,
348        source_file,
349        projectors: inventory.projectors,
350        target_repo,
351        model_id,
352        namespace: permissions.namespace.clone(),
353        catalog_create_pr,
354        experimental: params.experimental,
355        job_plan,
356        spec,
357    })
358}
359
360/// Format a byte count as a human-readable size.
361pub fn format_size(bytes: u64) -> String {
362    const KB: u64 = 1024;
363    const MB: u64 = 1024 * KB;
364    const GB: u64 = 1024 * MB;
365    const TB: u64 = 1024 * GB;
366
367    if bytes >= TB {
368        format!("{:.1} TB", bytes as f64 / TB as f64)
369    } else if bytes >= GB {
370        format!("{:.1} GB", bytes as f64 / GB as f64)
371    } else if bytes >= MB {
372        format!("{:.1} MB", bytes as f64 / MB as f64)
373    } else if bytes >= KB {
374        format!("{:.1} KB", bytes as f64 / KB as f64)
375    } else {
376        format!("{bytes} B")
377    }
378}
379
380fn parse_repo(repo: &str) -> Result<(String, String)> {
381    let parts: Vec<&str> = repo.splitn(2, '/').collect();
382    if parts.len() != 2 {
383        anyhow::bail!("Invalid repo format: '{}'. Expected 'owner/name'.", repo);
384    }
385    Ok((parts[0].to_string(), parts[1].to_string()))
386}
387
388fn resolve_model_id(
389    explicit_model_id: Option<String>,
390    source_repo: &str,
391    source_file: &str,
392    quant_name: &str,
393) -> Result<String> {
394    if let Some(model_id) = explicit_model_id {
395        model_ref::ModelRef::parse(&model_id)
396            .with_context(|| format!("invalid --model-id {model_id:?}"))?;
397        return Ok(model_id);
398    }
399
400    Ok(model_ref::format_gguf_selection_ref(
401        source_repo,
402        source_file,
403        quant_name,
404    ))
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn groups_sharded_quant_files() {
413        let quants = discover_quants_from_gguf_files(vec![
414            (
415                "UD-Q4_K_XL/Qwen3-32B-UD-Q4_K_XL-00002-of-00002.gguf".to_string(),
416                20,
417            ),
418            (
419                "UD-Q4_K_XL/Qwen3-32B-UD-Q4_K_XL-00001-of-00002.gguf".to_string(),
420                10,
421            ),
422        ]);
423
424        assert_eq!(quants.len(), 1);
425        assert_eq!(quants[0].name, "UD-Q4_K_XL");
426        assert_eq!(quants[0].shard_count, 2);
427        assert_eq!(quants[0].total_bytes, 30);
428        assert!(quants[0].first_file.ends_with("00001-of-00002.gguf"));
429    }
430
431    #[test]
432    fn groups_root_quant_files_by_selector() {
433        let quants = discover_quants_from_gguf_files(vec![
434            ("Qwen3-8B-Q4_K_M.gguf".to_string(), 5),
435            ("Qwen3-8B-Q8_0.gguf".to_string(), 9),
436        ]);
437
438        let names = quants
439            .iter()
440            .map(|quant| quant.name.as_str())
441            .collect::<Vec<_>>();
442        assert_eq!(names, vec!["Q4_K_M", "Q8_0"]);
443    }
444
445    #[test]
446    fn separates_multimodal_projectors_from_model_quants() {
447        let inventory = discover_inventory_from_gguf_files(vec![
448            (
449                "UD-Q2_K_XL/Inkling-UD-Q2_K_XL-00001-of-00008.gguf".to_string(),
450                317,
451            ),
452            ("mmproj-BF16.gguf".to_string(), 183),
453        ]);
454
455        assert_eq!(inventory.quants.len(), 1);
456        assert_eq!(inventory.quants[0].name, "UD-Q2_K_XL");
457        assert_eq!(
458            inventory.projectors,
459            vec![DiscoveredProjector {
460                path: "mmproj-BF16.gguf".to_string(),
461                total_bytes: 183,
462            }]
463        );
464    }
465
466    #[test]
467    fn accepts_explicit_model_id_coordinate() {
468        let model_id = resolve_model_id(
469            Some("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string()),
470            "unsloth/gemma-4-E4B-it-GGUF",
471            "gemma-4-E4B-it-Q4_K_M.gguf",
472            "Q4_K_M",
473        )
474        .expect("valid model id");
475
476        assert_eq!(model_id, "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M");
477    }
478
479    #[test]
480    fn rejects_explicit_model_id_without_repo_coordinate() {
481        let error = resolve_model_id(
482            Some("gemma-4-E4B-it-Q4_K_M".to_string()),
483            "unsloth/gemma-4-E4B-it-GGUF",
484            "gemma-4-E4B-it-Q4_K_M.gguf",
485            "Q4_K_M",
486        )
487        .expect_err("invalid model id should fail");
488
489        assert!(
490            error.to_string().contains("invalid --model-id"),
491            "{error:#}"
492        );
493    }
494
495    #[test]
496    fn derives_model_id_from_selected_quant() {
497        let model_id = resolve_model_id(
498            None,
499            "unsloth/gemma-4-E4B-it-GGUF",
500            "gemma-4-E4B-it-Q4_K_M.gguf",
501            "Q4_K_M",
502        )
503        .expect("derived model id");
504
505        assert_eq!(model_id, "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M");
506    }
507}