Files
cortiq-gateway/src/import.rs
T
infosave2007 15de36be3b feat: ops & UX batch — jobs survive restarts, logs/system in the console, media queue, gallery, chat, exact stream tokens
Fifteen changes aimed at the first day of real use:

- Import and media jobs persist to <data>/jobs-*.json and reload on start
  (running ones settle as errors); the Register button no longer vanishes
  because the gateway restarted.
- Docker ships the cortiq binary (built in the builder stage) — local .cmf
  chat models and image/video generation now work in containers, where
  auto-install never could (no cargo in the runtime image). libvulkan1 added
  for optional GPU offload.
- Open admin console on 0.0.0.0 without a token now warns in the log and
  shows a dismissible banner — the VPS footgun is at least labeled.
- "Restart gateway" button in Settings: stops managed model servers, spawns
  a fresh copy (bind retried while the port frees), Docker relies on the
  container restart policy instead.
- Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's
  own RSS), free disk at the models dir — sampled by sysinfo every 2 s.
- Featured model cards warn "not enough disk" / "may not fit in RAM" before
  the download, from the same live snapshot.
- Log viewer in the sidebar: a 500-line in-memory ring fed by a second
  ANSI-free tracing layer; filter, level, pause, copy.
- Update check: crates.io polled twice a day, the console shows an
  "update available" notice.
- Media generations queue up (with position shown) instead of being
  rejected; progress gained an honest ETA from measured throughput.
- Generation gallery under the image/video tabs: lazy PPM thumbnails,
  click to reopen with seed/steps, delete.
- Playground chat is a real dialogue now: history is resent, bubbles,
  Clear, Cmd/Ctrl+Enter.
- "Connect your app" snippets (curl / Python / JS) on the keys page, with
  the real key inlined right after creation.
- One-click benchmark on the Models page via `cortiq bench --json`.
- Config export now downloads the actual gateway.toml (comments intact);
  import accepts TOML (validated, hot-reloaded) and the legacy JSON.
- Streaming token counts are exact when the model server provides usage
  (cortiq-server patch, shipped separately); the gateway fallback now
  counts content chunks (1/token on cortiq) instead of chars/4.

Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
2026-08-07 17:07:48 +03:00

1318 lines
48 KiB
Rust

//! CMF model factory: search HuggingFace, then convert a chosen repo to a
//! local quantized `.cmf` via the Python converter — as a tracked, streamed
//! background job so the admin UI can show live progress.
use crate::config::CmfCfg;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::Notify;
/// Parse a `@PROGRESS <0..1> <phase>` marker emitted by the converter.
fn parse_progress(line: &str) -> Option<(f32, String)> {
let rest = line.strip_prefix("@PROGRESS ")?;
let mut it = rest.splitn(2, ' ');
let frac: f32 = it.next()?.trim().parse().ok()?;
let phase = it.next().unwrap_or("").trim().to_string();
Some((frac.clamp(0.0, 1.0), phase))
}
/// Remove a partial/aborted output — `.cmf` may be a file or a tensor-dir.
fn cleanup_output(path: &str) {
let p = std::path::Path::new(path);
if p.is_dir() {
let _ = std::fs::remove_dir_all(p);
} else if p.exists() {
let _ = std::fs::remove_file(p);
}
}
/// Bytes an output occupies: a file's length, or the recursive sum for a sharded
/// `.cmf` directory. 0 when nothing has been written yet.
pub fn path_size(p: &std::path::Path) -> u64 {
match std::fs::metadata(p) {
Ok(m) if m.is_file() => m.len(),
Ok(m) if m.is_dir() => std::fs::read_dir(p)
.map(|rd| {
rd.filter_map(|e| e.ok())
.map(|e| path_size(&e.path()))
.sum()
})
.unwrap_or(0),
_ => 0,
}
}
/// Aborts the wrapped task when it goes out of scope — used for the progress
/// poller so every early return in a download path stops it.
struct AbortOnDrop(tokio::task::JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
/// Drive progress from bytes on disk. curl's own meter is unreliable here (its
/// bar is redrawn with `\r` into a pipe, and a sharded download runs one curl
/// per part), but the output size never lies.
fn spawn_size_poller(store: Arc<JobStore>, id: String, path: String) -> AbortOnDrop {
AbortOnDrop(tokio::spawn(async move {
let p = std::path::PathBuf::from(&path);
loop {
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
let done = path_size(&p);
if done > 0 {
store.set_bytes(&id, done);
}
}
}))
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Job id from repo (deterministic) — same repo+variant reuses same id, so
/// the job list doesn't fill with duplicates and "already downloaded" can be detected.
pub fn gen_id(repo: &str) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
repo.hash(&mut h);
format!("{:016x}", h.finish())
}
/// Parameters chosen in the import wizard.
#[derive(Clone, Debug, Deserialize)]
pub struct ImportParams {
pub repo: String,
#[serde(default = "default_quant")]
pub quant: String,
#[serde(default)]
pub name: String, // output basename; empty → derived from repo
/// For sharded CMF repos (e.g. DeepSeek with `parts-q2tp-v2`/`parts-q4tp`), which
/// variant directory to download. Empty = auto (first .cmf or first variant).
#[serde(default)]
pub variant: Option<String>,
// ── advanced ──
#[serde(default)]
pub linear_core: Option<String>, // gated_delta_net | vmf_phase
#[serde(default)]
pub nphase: Option<u32>,
#[serde(default)]
pub vbit_shape: Option<String>, // log2 | cubic
#[serde(default)]
pub mean_bits: Option<f32>,
#[serde(default)]
pub shard_max_gb: Option<f32>,
#[serde(default)]
pub skip_mtp: bool,
/// O(1) Nyström attention hint (native converter, cortiq ≥ 0.2.0):
/// `all` | `deepN` | `i,j,k`. Weights pass through unchanged — the runtime
/// reads the hint at load. Empty/None/"off" = exact attention.
#[serde(default)]
pub o1: Option<String>,
/// Landmark budget for the o1 hint (validated default 32).
#[serde(default)]
pub o1_m: Option<usize>,
/// Exact-window width for the o1 hint (validated default 128).
#[serde(default)]
pub o1_window: Option<usize>,
/// Permanent exact sink keys for the o1 hint (validated default 4).
#[serde(default)]
pub o1_sink: Option<usize>,
}
fn default_quant() -> String {
"Q8_2F".into()
}
/// Live state of one conversion job.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Job {
pub id: String,
pub repo: String,
pub quant: String,
pub output: String,
/// Output basename without `.cmf` — the id the UI uses to match a job to the
/// featured card that started it, and to the file on disk.
pub name: String,
/// Sharded-variant directory / `.cmf` filename this job downloads, if any.
pub variant: Option<String>,
pub state: String, // running | done | error | cancelled
pub log: Vec<String>,
pub started: u64,
pub finished: Option<u64>,
pub size_bytes: Option<u64>,
pub progress: Option<f32>, // 0..1, parsed from converter @PROGRESS
pub phase: Option<String>, // current phase label
/// Expected download size (from the HF tree) and bytes already on disk —
/// what makes the percentage in the UI real rather than a guess.
pub total_bytes: Option<u64>,
pub done_bytes: Option<u64>,
}
#[derive(Default)]
pub struct JobStore {
jobs: Mutex<HashMap<String, Job>>,
/// Cancel handles, keyed by job id (not serialized). Notifying aborts the
/// converter process and cleans up its partial output.
cancels: Mutex<HashMap<String, Arc<Notify>>>,
/// When set, every state change lands in this JSON file, so the job list —
/// and its Register buttons — survives a gateway restart.
persist: Mutex<Option<std::path::PathBuf>>,
last_save: Mutex<Option<std::time::Instant>>,
}
impl JobStore {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Load previously saved jobs and start persisting to `path`. A job that
/// was `running` when the gateway died is settled as an error — its process
/// is gone; the file it produced (if complete) is still on disk.
pub fn attach_persistence(&self, path: std::path::PathBuf) {
if let Ok(text) = std::fs::read_to_string(&path) {
if let Ok(mut jobs) = serde_json::from_str::<Vec<Job>>(&text) {
let mut g = self.jobs.lock().unwrap();
for j in jobs.drain(..) {
let mut j = j;
if j.state == "running" {
j.state = "error".into();
j.log.push("✗ interrupted by a gateway restart".into());
j.finished = Some(now());
}
g.insert(j.id.clone(), j);
}
}
}
*self.persist.lock().unwrap() = Some(path);
}
fn save(&self, force: bool) {
let Some(path) = self.persist.lock().unwrap().clone() else {
return;
};
if !force {
let mut last = self.last_save.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < std::time::Duration::from_secs(2) {
return;
}
}
*last = Some(std::time::Instant::now());
}
let jobs: Vec<Job> = self.jobs.lock().unwrap().values().cloned().collect();
if let Ok(json) = serde_json::to_vec(&jobs) {
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
fn insert(&self, job: Job) {
self.jobs.lock().unwrap().insert(job.id.clone(), job);
self.save(true);
}
fn set_cancel(&self, id: &str, n: Arc<Notify>) {
self.cancels.lock().unwrap().insert(id.to_string(), n);
}
fn drop_cancel(&self, id: &str) {
self.cancels.lock().unwrap().remove(id);
}
/// Signal a running job to abort. Returns false if the job isn't cancellable.
pub fn cancel(&self, id: &str) -> bool {
let running = matches!(
self.jobs.lock().unwrap().get(id).map(|j| j.state.as_str()),
Some("running")
);
if !running {
return false;
}
if let Some(n) = self.cancels.lock().unwrap().get(id) {
n.notify_one();
true
} else {
false
}
}
fn push_line(&self, id: &str, line: String) {
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
j.log.push(line);
let n = j.log.len();
if n > 200 {
j.log.drain(0..n - 200); // keep the tail
}
}
}
fn set_progress(&self, id: &str, frac: f32, phase: String) {
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
let f = frac.clamp(0.0, 1.0);
let cur = j.progress.unwrap_or(0.0);
// monotonic only — never go backwards (curl reprints, re-download),
// but an unset bar takes whatever the first reading is
if j.progress.is_none() || f > cur + 0.001 {
j.progress = Some(f);
}
if !phase.is_empty() {
j.phase = Some(phase);
}
}
self.save(false);
}
/// Expected total download size, once the HF tree is known.
fn set_total(&self, id: &str, total: u64) {
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
if total > 0 {
j.total_bytes = Some(total);
}
}
}
/// Report bytes on disk; derives the percentage whenever the total is known.
fn set_bytes(&self, id: &str, done: u64) {
let total = {
let mut g = self.jobs.lock().unwrap();
let Some(j) = g.get_mut(id) else { return };
if j.state != "running" {
return;
}
if done > j.done_bytes.unwrap_or(0) {
j.done_bytes = Some(done);
}
j.total_bytes
};
if let Some(t) = total.filter(|t| *t > 0) {
self.set_progress(id, done as f32 / t as f32, "downloading".into());
}
}
fn set_phase(&self, id: &str, phase: String) {
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
j.phase = Some(phase);
// keep progress as is; if it was 0.0, make it indeterminate (None) so bar animates
if j.progress == Some(0.0) {
j.progress = None;
}
}
}
fn update_quant(&self, id: &str, quant: String) {
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
j.quant = quant.clone();
if !j.log.is_empty() && j.log[0].starts_with("→ downloading ready .cmf model") {
j.log[0] = format!("→ downloading ready .cmf model {} ({})", j.repo, quant);
}
}
}
fn set_state(&self, id: &str, state: &str) {
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
j.state = state.into();
j.finished = Some(now());
}
self.save(true);
}
fn finish(&self, id: &str, ok: bool) {
if let Some(j) = self.jobs.lock().unwrap().get_mut(id) {
if j.state == "cancelled" {
return; // a concurrent cancel already settled this job
}
j.finished = Some(now());
let on_disk = path_size(std::path::Path::new(&j.output));
j.size_bytes = if on_disk > 0 { Some(on_disk) } else { None };
let done = ok && on_disk > 0;
j.state = if done { "done" } else { "error" }.into();
if done {
j.progress = Some(1.0);
j.done_bytes = Some(on_disk);
j.total_bytes = Some(j.total_bytes.unwrap_or(on_disk).max(on_disk));
}
}
self.save(true);
}
pub fn get(&self, id: &str) -> Option<Job> {
self.jobs.lock().unwrap().get(id).cloned()
}
/// Delete a finished job together with its converted output file(s).
/// Refuses a running job (cancel it first). `Ok(false)` if unknown.
pub fn delete(&self, id: &str) -> Result<bool, String> {
let job = self.jobs.lock().unwrap().get(id).cloned();
let Some(job) = job else { return Ok(false) };
if job.state == "running" {
return Err("cancel the running job before deleting".into());
}
cleanup_output(&job.output);
self.cancels.lock().unwrap().remove(id);
self.jobs.lock().unwrap().remove(id);
self.save(true);
Ok(true)
}
pub fn list(&self) -> Vec<Job> {
let mut map: HashMap<String, Job> = HashMap::new();
for j in self.jobs.lock().unwrap().values().cloned() {
// dedupe by repo+variant — keep the latest (so re-download doesn't create
// duplicate rows) while two variants of one repo stay separate rows
let key = format!("{}::{}", j.repo, j.variant.clone().unwrap_or_default());
match map.get(&key) {
Some(prev) if prev.started >= j.started => {}
_ => {
map.insert(key, j);
}
}
}
let mut v: Vec<Job> = map.into_values().collect();
v.sort_by_key(|x| std::cmp::Reverse(x.started));
v.truncate(20);
v
}
}
/// Proxy HuggingFace model search (avoids browser CORS + adds our token).
pub async fn hf_search(
query: &str,
limit: usize,
token: Option<&str>,
) -> Result<serde_json::Value, String> {
let q: String = url_escape(query);
let sort = if query.trim().is_empty() {
"trendingScore"
} else {
"downloads"
};
let url = format!(
"https://huggingface.co/api/models?search={q}&sort={sort}&direction=-1&limit={limit}&full=false"
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
let mut req = client.get(&url).header("User-Agent", "cortiq-gateway");
if let Some(t) = token {
req = req.bearer_auth(t);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HuggingFace API {}", resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn hf_search_author(
author: &str,
limit: usize,
token: Option<&str>,
) -> Result<serde_json::Value, String> {
let url = format!(
"https://huggingface.co/api/models?author={}&limit={}&sort=trendingScore&direction=-1",
url_escape(author),
limit
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
let mut req = client.get(&url).header("User-Agent", "cortiq-gateway");
if let Some(t) = token {
req = req.bearer_auth(t);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HuggingFace API {}", resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn hf_model_size(id: &str, token: Option<&str>) -> Option<u64> {
let url = format!("https://huggingface.co/api/models/{}", url_escape(id));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()
.ok()?;
let mut req = client.get(&url).header("User-Agent", "cortiq-gateway");
if let Some(t) = token {
req = req.bearer_auth(t);
}
let resp = req.send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let v: serde_json::Value = resp.json().await.ok()?;
v.get("usedStorage").and_then(|x| x.as_u64())
}
/// Per-variant sizes for a repo (for CMF repos with sharded directories like
/// `parts-q2tp-v2`, `parts-q4tp`). Returns `[(variant_name, bytes)]`. For a
/// single-file repo returns one entry. Empty = tree unreachable.
pub async fn hf_repo_variants(id: &str, token: Option<&str>) -> Vec<(String, u64)> {
let url = format!(
"https://huggingface.co/api/models/{}/tree/main?recursive=true",
url_escape(id)
);
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut req = client.get(&url).header("User-Agent", "cortiq-gateway");
if let Some(t) = token {
req = req.bearer_auth(t);
}
let resp = match req.send().await {
Ok(r) if r.status().is_success() => r,
_ => return Vec::new(),
};
let arr: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let list = match arr.as_array() {
Some(a) => a,
None => return Vec::new(),
};
use std::collections::HashMap;
let mut dir_sizes: HashMap<String, u64> = HashMap::new();
let mut single_cmf: Vec<(String, u64)> = Vec::new();
for item in list {
if item.get("type").and_then(|t| t.as_str()) != Some("file") {
continue;
}
let path = match item.get("path").and_then(|p| p.as_str()) {
Some(p) => p,
None => continue,
};
let sz = item
.get("size")
.and_then(|v| v.as_u64())
.or_else(|| {
item.get("lfs")
.and_then(|l| l.get("size"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
if sz == 0 {
continue;
}
if path.to_ascii_lowercase().ends_with(".cmf") {
single_cmf.push((path.to_string(), sz));
} else if path.contains('/') && path.starts_with("parts-") {
let top = path.split('/').next().unwrap_or(path).to_string();
*dir_sizes.entry(top).or_default() += sz;
}
}
if !single_cmf.is_empty() {
return single_cmf;
}
let mut out: Vec<(String, u64)> = dir_sizes.into_iter().collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// `Content-Length` of a download URL (redirects followed) — the fallback total
/// when the repo tree didn't tell us how big the file is.
async fn head_content_length(url: &str, token: Option<&str>) -> Option<u64> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok()?;
let mut req = client.head(url).header("User-Agent", "cortiq-gateway");
if let Some(t) = token {
req = req.bearer_auth(t);
}
let resp = req.send().await.ok()?;
if !resp.status().is_success() {
return None;
}
resp.headers()
.get(reqwest::header::CONTENT_LENGTH)?
.to_str()
.ok()?
.parse()
.ok()
}
pub fn format_bytes(n: u64) -> String {
if n >= 1_000_000_000 {
format!("{:.1} GB", n as f64 / 1_000_000_000.0)
} else if n >= 1_000_000 {
format!("{} MB", n / 1_000_000)
} else if n >= 1_000 {
format!("{} KB", n / 1_000)
} else {
format!("{n} B")
}
}
fn url_escape(s: &str) -> String {
s.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '/' => c.to_string(),
' ' => "+".to_string(),
_ => format!("%{:02X}", c as u32),
})
.collect()
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
pub fn sanitize_for_id(s: &str) -> String {
sanitize(&s.to_lowercase())
}
/// Kick off a conversion. Returns the job id immediately; progress streams
/// Map the UI quant label to the native `cortiq` quant flag.
fn map_quant(q: &str) -> &'static str {
match q.to_ascii_uppercase().as_str() {
s if s.starts_with("Q4T") || s == "Q4_TILED" => "q4t",
s if s.starts_with("Q4") => "q4",
"Q8_2F" | "Q82F" => "q8_2f",
"F16" | "FP16" => "f16",
"VBIT" => "vbit",
"Q1" => "q1",
"Q1P" | "Q1_PTQ" => "q1p",
"Q1S" | "Q1_MASK" => "q1s",
"Q1T" | "Q1_TERNARY" => "q1t",
_ => "q8",
}
}
fn is_cmf_repo(repo: &str) -> bool {
let lower = repo.to_ascii_lowercase();
lower.contains("cmf") || lower.ends_with(".cmf")
}
async fn download_cmf_repo(
repo: String,
output_abs: String,
hf_token: Option<String>,
store: Arc<JobStore>,
id: String,
cancel: Arc<Notify>,
variant: Option<String>,
) {
// don't show 0% "listing files" — it looks stuck; show indeterminate "preparing"
store.set_phase(&id, "preparing".into());
let tree_url = format!("https://huggingface.co/api/models/{repo}/tree/main?recursive=true");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let mut req = client.get(&tree_url).header("User-Agent", "cortiq-gateway");
if let Some(t) = &hf_token {
req = req.bearer_auth(t);
}
let mut cmf_files: Vec<(String, u64)> = Vec::new();
let mut sharded_dirs: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
let mut dir_sizes: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
if let Ok(resp) = req.send().await {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if let Some(arr) = json.as_array() {
for item in arr {
if item.get("type").and_then(|t| t.as_str()) != Some("file") {
continue;
}
if let Some(path) = item.get("path").and_then(|p| p.as_str()) {
let sz = item
.get("size")
.and_then(|v| v.as_u64())
.or_else(|| {
item.get("lfs")
.and_then(|l| l.get("size"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
if path.to_ascii_lowercase().ends_with(".cmf") {
cmf_files.push((path.to_string(), sz));
} else if path.contains('/') && path.starts_with("parts-") {
let top = path.split('/').next().unwrap_or(path).to_string();
sharded_dirs
.entry(top.clone())
.or_default()
.push(path.to_string());
*dir_sizes.entry(top).or_default() += sz;
}
}
}
}
}
}
}
// Decide what to download: single .cmf file vs sharded variant directory
let is_sharded = !sharded_dirs.is_empty();
let chosen_variant = variant
.as_deref()
.map(|v| v.trim())
.filter(|v| !v.is_empty());
if is_sharded {
// pick variant: explicit > first alphabetically
let target_dir = if let Some(v) = chosen_variant {
if sharded_dirs.contains_key(v) {
v.to_string()
} else {
sharded_dirs.keys().next().cloned().unwrap_or_default()
}
} else {
// prefer smallest? use first sorted
let mut keys: Vec<String> = sharded_dirs.keys().cloned().collect();
keys.sort();
keys.into_iter().next().unwrap_or_default()
};
let files = sharded_dirs.get(&target_dir).cloned().unwrap_or_default();
if files.is_empty() {
store.push_line(&id, format!("✗ variant '{target_dir}' not found"));
store.finish(&id, false);
store.drop_cancel(&id);
return;
}
let total_sz = dir_sizes.get(&target_dir).copied().unwrap_or(0);
let q = if target_dir.contains("q2tp") {
"Q2TP"
} else if target_dir.contains("q4tp") {
"Q4TP"
} else {
"READY_CMF"
};
store.update_quant(&id, q.to_string());
store.push_line(
&id,
format!(
"→ downloading {} ({} files, {}) from HuggingFace...",
target_dir,
files.len(),
format_bytes(total_sz)
),
);
store.set_total(&id, total_sz);
store.set_progress(&id, 0.001, "downloading".into());
// output is a directory: ensure {base}.cmf is a directory
let out_dir = std::path::Path::new(&output_abs);
// Drop a previous copy first (re-download / update). There is no resume,
// so it would be overwritten anyway — and leaving it there would make the
// size poller read the old bytes and report 100% before we transfer any.
cleanup_output(&output_abs);
// if output ends with .cmf and variant is sharded, keep it as dir (e.g. model.cmf/parts-*)
// create base dir
let _ = std::fs::create_dir_all(out_dir);
// live percentage from bytes landing on disk (curl runs quiet, per part)
let _poller = spawn_size_poller(store.clone(), id.clone(), output_abs.clone());
// download each part file
for (idx, rel) in files.iter().enumerate() {
let filename = rel.rsplit('/').next().unwrap_or(rel);
let dest = out_dir.join(filename);
let url = format!("https://huggingface.co/{repo}/resolve/main/{rel}");
store.push_line(&id, format!(" [{}/{}] {filename}", idx + 1, files.len()));
let mut cmd = tokio::process::Command::new("curl");
cmd.arg("-f")
.arg("-L")
.arg("-s")
.arg("-S")
.arg("-o")
.arg(&dest)
.arg(&url);
if let Some(t) = &hf_token {
cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
}
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
store.push_line(&id, format!("✗ failed {filename}: {e}"));
cleanup_output(&output_abs);
store.finish(&id, false);
store.drop_cancel(&id);
return;
}
};
let t_err = child.stderr.take().map(|s| {
let st = store.clone();
let jid = id.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(s).lines();
while let Ok(Some(l)) = lines.next_line().await {
let l = l.trim().to_string();
if !l.is_empty() {
st.push_line(&jid, l);
}
}
})
});
// Race the part against a cancel — a single part can be many GB, so
// waiting for it to finish first would make Cancel look dead.
let status = tokio::select! {
r = child.wait() => {
if let Some(t) = t_err { t.abort(); }
r.map_err(|e| e.to_string())
}
_ = cancel.notified() => {
let _ = child.start_kill();
let _ = child.wait().await;
if let Some(t) = t_err { t.abort(); }
cleanup_output(&output_abs);
store.push_line(&id, "✗ cancelled by user — partial output removed".into());
store.set_state(&id, "cancelled");
store.drop_cancel(&id);
return;
}
};
match status {
Ok(s) if s.success() => {
// exact byte count after each part (the poller covers the gaps)
let on_disk = path_size(out_dir);
if total_sz > 0 {
store.set_bytes(&id, on_disk);
} else {
let frac = (idx + 1) as f32 / files.len() as f32;
store.set_progress(&id, frac, "downloading".into());
}
}
Ok(s) => {
store.push_line(&id, format!("✗ failed {filename}: exit {:?}", s.code()));
cleanup_output(&output_abs);
store.finish(&id, false);
store.drop_cancel(&id);
return;
}
Err(e) => {
store.push_line(&id, format!("✗ failed {filename}: {e}"));
cleanup_output(&output_abs);
store.finish(&id, false);
store.drop_cancel(&id);
return;
}
}
}
store.push_line(&id, "✓ done".into());
store.finish(&id, true);
store.drop_cancel(&id);
return;
}
// single-file path (original logic)
let (cmf_path, cmf_size) = if !cmf_files.is_empty() {
// if variant specified and matches a file prefix, prefer it
if let Some(v) = chosen_variant {
cmf_files
.iter()
.find(|(p, _)| p.contains(v))
.cloned()
.unwrap_or_else(|| cmf_files[0].clone())
} else {
cmf_files[0].clone()
}
} else {
let repo_name = repo.rsplit('/').next().unwrap_or(&repo);
(format!("{repo_name}.cmf"), 0)
};
let path_lower = cmf_path.to_ascii_lowercase();
let detected_quant =
if path_lower.contains("-q1t.") || repo.to_ascii_lowercase().contains("2bit") {
"Q1T".to_string()
} else if path_lower.contains("-q1.") || repo.to_ascii_lowercase().contains("cmf") {
"Q1".to_string()
} else if path_lower.contains("-q4.") {
"Q4".to_string()
} else if path_lower.contains("-q8.") {
"Q8".to_string()
} else {
"READY_CMF".to_string()
};
store.update_quant(&id, detected_quant);
let download_url = format!("https://huggingface.co/{repo}/resolve/main/{cmf_path}");
// The tree listing is the usual source of the size; ask the CDN directly when
// it wasn't (private repo, tree fetch failed) so the bar still gets a total.
let total = if cmf_size > 0 {
cmf_size
} else {
head_content_length(&download_url, hf_token.as_deref())
.await
.unwrap_or(0)
};
store.push_line(&id, {
let sz = if total > 0 {
format!(" ({})", format_bytes(total))
} else {
String::new()
};
format!("→ downloading {cmf_path}{sz} from HuggingFace...")
});
store.set_total(&id, total);
store.set_progress(&id, 0.001, "downloading".into());
// Drop a previous copy first (re-download / update). There is no resume, so
// it would be overwritten anyway — and leaving it there would make the size
// poller read the old bytes and report 100% before we transfer any.
cleanup_output(&output_abs);
// Progress comes from bytes on disk only. curl's own meter is worse than
// useless here: with -L it prints a complete 0→100% bar for the redirect hop
// before the real transfer starts, which used to pin the job at 100%.
let _poller = spawn_size_poller(store.clone(), id.clone(), output_abs.clone());
let mut cmd = tokio::process::Command::new("curl");
cmd.arg("-f")
.arg("-L")
.arg("-s")
.arg("-S")
.arg("-o")
.arg(&output_abs);
if let Some(t) = &hf_token {
cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
}
cmd.arg(&download_url);
cmd.stderr(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::null());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
store.push_line(&id, format!("✗ failed to start curl download: {e}"));
store.finish(&id, false);
store.drop_cancel(&id);
return;
}
};
// -sS leaves stderr for real errors only — drain it into the log so a failure
// says what went wrong instead of just an exit code.
let stderr = child.stderr.take();
let mut t_err = None;
if let Some(err_stream) = stderr {
let store_clone = store.clone();
let id_clone = id.clone();
t_err = Some(tokio::spawn(async move {
let mut lines = BufReader::new(err_stream).lines();
while let Ok(Some(l)) = lines.next_line().await {
let l = l.trim().to_string();
if !l.is_empty() {
store_clone.push_line(&id_clone, l);
}
}
}));
}
tokio::select! {
status_res = child.wait() => {
if let Some(t) = t_err { t.abort(); }
match status_res {
Ok(status) if status.success() => {
store.push_line(&id, "✓ done".into());
store.finish(&id, true);
}
Ok(status) => {
cleanup_output(&output_abs);
// curl 23 = write error: almost always a non-writable models dir
let hint = match status.code() {
Some(23) => " (write error — is the models dir writable?)",
Some(22) => " (HTTP error — repo/file not found or auth required)",
_ => "",
};
store.push_line(&id, format!("✗ download failed: exit code {:?}{hint}", status.code()));
store.finish(&id, false);
}
Err(e) => {
cleanup_output(&output_abs);
store.push_line(&id, format!("✗ download process error: {e}"));
store.finish(&id, false);
}
}
}
_ = cancel.notified() => {
let _ = child.start_kill();
let _ = child.wait().await;
if let Some(t) = t_err { t.abort(); }
cleanup_output(&output_abs);
store.push_line(&id, "✗ cancelled by user — partial output removed".into());
store.set_state(&id, "cancelled");
}
}
store.drop_cancel(&id);
}
/// into the job log. Fails fast if the converter script is missing.
pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Result<String, String> {
if p.repo.trim().is_empty() {
return Err("empty repo id".into());
}
// Native `cortiq convert` (from crates.io, no Python) handles standard + MoE
// models; the bundled Python converter is used only for advanced options it
// doesn't support yet (linear-attention folding, v-bit shaping).
let use_python = p.linear_core.is_some() || p.vbit_shape.is_some() || p.mean_bits.is_some();
if use_python && !std::path::Path::new(&cfg.converter).exists() {
return Err(format!(
"advanced options need the Python converter, not found: {}",
cfg.converter
));
}
// O(1) attention hint — native-converter feature (cortiq ≥ 0.2.0). Fail fast
// with a clear message instead of a mid-job "unexpected argument" error.
let o1 =
p.o1.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty() && *s != "off")
.map(str::to_string);
if o1.is_some() {
if use_python {
return Err(
"O(1) attention is supported by the native converter only — remove the \
linear-core / v-bit shape / mean-bits options to use it"
.into(),
);
}
if p.repo.to_ascii_lowercase().contains("gguf") {
return Err("O(1) attention is not supported for GGUF imports yet".into());
}
let ver = crate::cmf_runtime::installed_version(&cfg.cortiq_bin);
if let Some(v) = &ver {
if crate::cmf_runtime::version_lt(v, "0.2.0") {
return Err(format!(
"O(1) attention needs cortiq ≥ 0.2.0 (installed: {v}) — update the runtime \
in Settings → Local models"
));
}
}
}
std::fs::create_dir_all(&cfg.models_dir).map_err(|e| {
format!(
"cannot create models dir {}: {e} — check that the gateway has write \
access (in Docker, mount a writable volume at the models path)",
cfg.models_dir
)
})?;
let mut base = if p.name.trim().is_empty() {
sanitize(p.repo.rsplit('/').next().unwrap_or(&p.repo))
} else {
sanitize(&p.name)
};
// for sharded variants make output distinct (e.g. DeepSeek parts-q2tp vs q4tp); single-file CMF ignores variant
if let Some(v) = p
.variant
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
if v.starts_with("parts-") {
let vs = sanitize(v);
if !base.ends_with(&vs) {
base = format!("{base}-{vs}");
}
}
}
let output = std::path::Path::new(&cfg.models_dir)
.join(format!("{base}.cmf"))
.to_string_lossy()
.to_string();
let output_abs = std::fs::canonicalize(&cfg.models_dir)
.map(|d| d.join(format!("{base}.cmf")).to_string_lossy().to_string())
.unwrap_or_else(|_| output.clone());
let id = gen_id(&format!(
"{}::{}",
p.repo,
p.variant.as_deref().unwrap_or("")
));
// if same repo/variant is already running, don't reset progress — just return it
if let Some(existing) = store.get(&id) {
if existing.state == "running" {
return Ok(id);
}
}
let is_cmf = is_cmf_repo(&p.repo);
let quant_display = if is_cmf {
let r = p.repo.to_lowercase();
if r.contains("2bit") || r.contains("q1t") {
"Q1T".to_string()
} else if r.contains("1.7bcmf")
|| r.contains("27bcmf")
|| r.contains("cmf")
|| p.quant.is_empty()
|| p.quant == "auto"
|| p.quant == "Q8_2F"
{
"Q1".to_string()
} else {
p.quant.clone()
}
} else if p.quant.is_empty() || p.quant == "auto" {
"q8".to_string()
} else {
p.quant.clone()
};
store.insert(Job {
id: id.clone(),
repo: p.repo.clone(),
quant: quant_display.clone(),
output: output_abs.clone(),
name: base.clone(),
variant: p
.variant
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
state: "running".into(),
log: if is_cmf {
vec![format!(
"→ downloading ready .cmf model {} ({})",
p.repo, quant_display
)]
} else {
vec![format!("→ converting {} to {} ({})", p.repo, base, p.quant)]
},
started: now(),
finished: None,
size_bytes: None,
progress: Some(0.0),
phase: Some("starting".into()),
total_bytes: None,
done_bytes: None,
});
let cancel = Arc::new(Notify::new());
store.set_cancel(&id, cancel.clone());
let hf_token = if cfg.hf_token_env.is_empty() {
None
} else {
std::env::var(&cfg.hf_token_env).ok()
};
if is_cmf && !use_python {
let ret_id = id.clone();
let variant = p.variant.clone();
tokio::spawn(async move {
download_cmf_repo(p.repo, output_abs, hf_token, store, id, cancel, variant).await;
});
return Ok(ret_id);
}
// Program + args: `cortiq convert` by default, or the Python converter for
// advanced options. Both stream `@PROGRESS <frac>` markers into the log.
let (program, args, workdir): (String, Vec<String>, std::path::PathBuf) = if use_python {
let conv = std::path::Path::new(&cfg.converter);
let mut a = vec![
std::fs::canonicalize(conv)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| cfg.converter.clone()),
"--model".into(),
p.repo.clone(),
"--quant".into(),
p.quant.clone(),
"--output".into(),
output_abs.clone(),
];
if let Some(lc) = &p.linear_core {
a.push("--linear-core".into());
a.push(lc.clone());
}
if let Some(n) = p.nphase {
a.push("--nphase".into());
a.push(n.to_string());
}
if let Some(vs) = &p.vbit_shape {
a.push("--vbit-shape".into());
a.push(vs.clone());
}
if let Some(mb) = p.mean_bits {
a.push("--mean-bits".into());
a.push(mb.to_string());
}
if let Some(g) = p.shard_max_gb {
a.push("--shard-max-gb".into());
a.push(g.to_string());
}
if p.skip_mtp {
a.push("--skip-mtp".into());
}
let wd = conv
.parent()
.map(|d| d.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
(cfg.python_bin.clone(), a, wd)
} else if p.repo.to_ascii_lowercase().contains("gguf") {
// GGUF repo → native `cortiq import-gguf` (downloads + dequantizes any
// common ggml quant type: Q4_0/1, Q5_0/1, Q8_0, Q2_K..Q6_K, IQ4_NL/XS).
let mut a = vec![
"import-gguf".into(),
p.repo.clone(),
"--quant".into(),
map_quant(&p.quant).into(),
"--output".into(),
output_abs.clone(),
];
if let Some(t) = &hf_token {
a.push("--hf-token".into());
a.push(t.clone());
}
(
cfg.cortiq_bin.clone(),
a,
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
)
} else {
// safetensors → native `cortiq convert` (dense / MoE / GatedDeltaNet).
let mut a = vec![
"convert".into(),
"--model".into(),
p.repo.clone(),
"--quant".into(),
map_quant(&p.quant).into(),
"--output".into(),
output_abs.clone(),
];
if let Some(t) = &hf_token {
a.push("--hf-token".into());
a.push(t.clone());
}
// O(1) attention hint (weights unchanged; the runtime reads it at load)
if let Some(spec) = &o1 {
a.push("--o1".into());
a.push(spec.clone());
if let Some(m) = p.o1_m {
a.push("--o1-m".into());
a.push(m.to_string());
}
if let Some(w) = p.o1_window {
a.push("--o1-window".into());
a.push(w.to_string());
}
if let Some(s) = p.o1_sink {
a.push("--o1-sink".into());
a.push(s.to_string());
}
}
(
cfg.cortiq_bin.clone(),
a,
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
)
};
let ret_id = id.clone();
let output_for_task = output_abs.clone();
tokio::spawn(async move {
let mut cmd = tokio::process::Command::new(&program);
cmd.args(&args)
.current_dir(&workdir)
.env("PYTHONUNBUFFERED", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// HF token for the Python converter (native cortiq gets --hf-token).
if use_python {
if let Some(tok) = hf_token {
cmd.env("HF_TOKEN", &tok).env("HUGGING_FACE_HUB_TOKEN", tok);
}
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
store.push_line(&id, format!("✗ spawn failed: {e}"));
store.finish(&id, false);
store.drop_cancel(&id);
return;
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// stdout: parse @PROGRESS markers into progress/phase; everything else logs.
let s1 = store.clone();
let id1 = id.clone();
let t_out = tokio::spawn(async move {
if let Some(o) = stdout {
let mut lines = BufReader::new(o).lines();
while let Ok(Some(l)) = lines.next_line().await {
if let Some((frac, phase)) = parse_progress(&l) {
s1.set_progress(&id1, frac, phase);
} else {
s1.push_line(&id1, l);
}
}
}
});
let s2 = store.clone();
let id2 = id.clone();
let t_err = tokio::spawn(async move {
if let Some(e) = stderr {
let mut lines = BufReader::new(e).lines();
while let Ok(Some(l)) = lines.next_line().await {
s2.push_line(&id2, l);
}
}
});
tokio::select! {
status = child.wait() => {
let _ = tokio::join!(t_out, t_err);
let ok = status.map(|s| s.success()).unwrap_or(false);
if !ok {
cleanup_output(&output_for_task); // drop partial/corrupt output
}
store.push_line(
&id,
if ok { "✓ done".into() } else { "✗ converter exited with error".into() },
);
store.finish(&id, ok);
}
_ = cancel.notified() => {
let _ = child.start_kill();
let _ = child.wait().await;
t_out.abort();
t_err.abort();
cleanup_output(&output_for_task);
store.push_line(&id, "✗ cancelled by user — partial output removed".into());
store.set_state(&id, "cancelled");
}
}
store.drop_cancel(&id);
});
Ok(ret_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_quant_variants() {
assert_eq!(map_quant("Q8_2F"), "q8_2f");
assert_eq!(map_quant("q82f"), "q8_2f");
assert_eq!(map_quant("Q8_ROW"), "q8");
assert_eq!(map_quant("Q4_BLOCK"), "q4");
assert_eq!(map_quant("Q4_TILED"), "q4t");
assert_eq!(map_quant("q4t"), "q4t");
assert_eq!(map_quant("vbit"), "vbit");
assert_eq!(map_quant("q1"), "q1");
assert_eq!(map_quant("Q1P"), "q1p");
assert_eq!(map_quant("q1s"), "q1s");
assert_eq!(map_quant("Q1T"), "q1t");
assert_eq!(map_quant("F16"), "f16");
}
#[test]
fn test_is_cmf_repo_check() {
assert!(is_cmf_repo("infosave/Bonsai-8B_2bit_cmf"));
assert!(is_cmf_repo("infosave/Bonsai-1.7Bcmf"));
assert!(is_cmf_repo("user/model.cmf"));
assert!(!is_cmf_repo("Qwen/Qwen2.5-0.5B-Instruct"));
}
}