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
16pub 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
30pub 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#[derive(Debug, Clone, Serialize)]
47pub struct DiscoveredQuant {
48 pub name: String,
50 pub shard_count: usize,
52 pub total_bytes: u64,
54 pub first_file: String,
56}
57
58#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
60pub struct DiscoveredProjector {
61 pub path: String,
63 pub total_bytes: u64,
65}
66
67#[derive(Debug, Clone, Default)]
69pub struct RepoGgufInventory {
70 pub quants: Vec<DiscoveredQuant>,
71 pub projectors: Vec<DiscoveredProjector>,
72}
73
74pub async fn list_quants(client: &HFClient, repo: &str) -> Result<Vec<DiscoveredQuant>> {
76 Ok(list_inventory(client, repo, None).await?.quants)
77}
78
79pub 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 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
111pub 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
137pub 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 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 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 quants.sort_by(|a, b| a.name.cmp(&b.name));
168 quants
169}
170
171pub 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(¶ms.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, ¶ms.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 let matched = quants
211 .iter()
212 .find(|q| q.name.eq_ignore_ascii_case(quant))
213 .or_else(|| {
214 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 let source_file = if let Some(shard) = split_gguf_shard_info(&matched.first_file) {
231 if shard.part != "00001" {
233 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 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 ¶ms.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 ¶ms.flavor,
273 params.timeout_seconds,
274 source_total_bytes,
275 )
276 .await?;
277
278 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 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
360pub 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}