//! GGUF models on the machines this backend can run them on, and the //! downloads that produce them. //! //! **A model file belongs to the machine that will serve it.** Everything //! here is addressed by a transport and a directory rather than by this //! process's own disk, because `llama-server` reads the file where it runs //! and a list or a download naming anywhere else is a claim about the wrong //! filesystem. //! //! **So a download runs there too** (2026-09-19, replacing the fetch this //! server used to perform itself onto its own disk). It is a detached `curl` //! on that machine, started by a script written here and never spoken to //! again. Three things follow, and all three are why it is shaped this way: //! //! - **Its state is a file beside the model**, `x.gguf.download`, written by //! that script at each step. Nothing about it is held in this process, so //! one download survives the phone locking, this backend restarting, and //! being watched from a second device -- and what is on that disk is the //! answer rather than something this server remembers about it. //! - **Progress is measured, never estimated.** The bytes are `wc -c` of the //! partial file and the total is the size HuggingFace published for it. A //! download whose process is gone is reported failed rather than left //! saying "running" for ever, which is what `kill -0` at each listing is //! for. //! - **Resume is guarded by identity.** The partial records the sha256 the //! file is meant to have; one written against a different sha is discarded //! rather than resumed onto, and the finished file is checked against it //! before it takes the real name. use std::collections::HashMap; use std::path::Path; use anyhow::{Context, Result}; use serde::Serialize; use crate::session::transport::{Launch, Transport}; /// Identifies this client to HuggingFace. They ask for one, and a request /// without it is more likely to be rate-limited. const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION")); /// A model file sitting on a machine, ready to run there. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalModel { /// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are already /// unique, so nothing has to invent an id. pub key: String, pub repo: String, pub file: String, pub bytes: u64, /// What the file says it is called (`general.name` in its own metadata), /// absent when it does not say or could not be read. Not a label: see /// [`labels`] for what a reader is actually shown, which needs the rest of /// the list to decide. #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, } /// What each of these models should be called on screen, in the same order. /// /// A model's own name is the best answer and is not always an answer at all: /// two quantisations of one model carry the same `general.name`, and a chip /// row with two identical chips is one you cannot choose from. So this is a /// cascade -- the model's own name, else its file name, else its full key -- /// and each model takes the first rung that nothing else on this machine /// shares. The last rung always terminates it, because the key is what makes /// these unique in the first place. /// /// Decided over the whole list rather than per model because ambiguity is a /// property of the set: the same file is unambiguous on a machine holding one /// quantisation and not on a machine holding three, and only the list knows /// which machine this is. pub fn labels(models: &[LocalModel]) -> Vec { let rungs = |model: &LocalModel| { [ model .name .clone() .filter(|name| names_the_file(name, model)), Some(model.file.trim_end_matches(".gguf").to_string()), Some(model.key.clone()), ] }; let mut taken: Vec> = vec![HashMap::new(); 3]; for model in models { for (rung, candidate) in rungs(model).into_iter().enumerate() { if let Some(candidate) = candidate { *taken[rung].entry(candidate).or_insert(0) += 1; } } } models .iter() .map(|model| { rungs(model) .into_iter() .enumerate() .find_map(|(rung, candidate)| { let candidate = candidate?; (taken[rung].get(&candidate) == Some(&1)).then_some(candidate) }) // Unreachable: the key rung is unique by construction. Said as // the key rather than as a panic, because a duplicate key would // mean the same file listed twice and a name is still the // honest thing to draw for it. .unwrap_or_else(|| model.key.clone()) }) .collect() } /// Whether a model's own `general.name` is a name for *this* model, or the /// converter's working directory wearing the same field. /// /// The field is filled in by whatever produced the file, and /// `convert_hf_to_gguf.py` fills it from the directory it converted -- so a /// model converted out of a folder called `hf` publishes `general.name = "hf"`, /// which is unique, passes the cascade above, and tells a reader nothing at /// all. Prism ML's Bonsai is the one here (reported 2026-09-21, drawn as "hf" /// in the model picker). /// /// The test is corroboration rather than a list of words to distrust: a real /// name shares something with where the file came from, both being about the /// same model, and a directory name picked up in passing does not. One word in /// common is enough. A name that fails it drops to the next rung, which is the /// file name the reader downloaded. fn names_the_file(name: &str, model: &LocalModel) -> bool { let words = |text: &str| -> Vec { text.split(|c: char| !c.is_ascii_alphanumeric()) .filter(|word| word.len() > 1) .map(str::to_ascii_lowercase) .collect() }; let from = words(&model.repo); let from_file = words(&model.file); words(name) .iter() .any(|word| from.contains(word) || from_file.contains(word)) } /// What a download is doing, or did. /// /// Flat rather than a tagged enum carrying its message, because the phone /// switches on this and a word it can compare is easier to render than a /// variant it has to destructure. There is no `finished`: a download that /// finished is a model, and it is in the list beside this one -- an outcome /// kept here as well would be the same fact stated twice, in two places that /// could disagree. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum DownloadState { Running, /// Reading the finished file back to check it against the hash /// HuggingFace publishes. Its own state because it takes real time on a /// multi-gigabyte file, and "still working" is the honest thing to show /// where a bar sitting at 100% for half a minute is not. Verifying, Failed, Cancelled, } impl DownloadState { fn from_word(word: &str) -> Option { match word { "running" => Some(Self::Running), "verifying" => Some(Self::Verifying), "failed" => Some(Self::Failed), "cancelled" => Some(Self::Cancelled), _ => None, } } } /// One download on one machine, as the phone sees it. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Download { /// `owner/repo/file.gguf`, the same key the finished model will have. /// One download per model at a time, so this identifies it. pub key: String, pub repo: String, pub file: String, pub state: DownloadState, pub done: u64, /// What HuggingFace published as the file's size, or absent when it /// published none. Absent means "not known", never "zero". #[serde(skip_serializing_if = "Option::is_none")] pub total: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// What HuggingFace says about one file before anything is fetched: how big /// it is, and what it should hash to. /// /// Both read here rather than on the machine doing the download, because this /// is the side with internet trust to spend (see [`search`]) and because the /// sha is what makes a resume safe -- the machine is handed the answer rather /// than asked to find it. #[derive(Debug, Clone)] pub struct Published { pub bytes: Option, /// The LFS object id, which for these repositories is the sha256 of the /// content -- a free integrity check rather than a second source of /// truth. Absent for a repository that publishes none, and then the file /// is not verified and a partial is never resumed onto. pub sha256: Option, } /// Refuses a key that is anything but `owner/repo/file.gguf`. /// /// The key comes from a phone, so it is treated as hostile: it reaches a /// shell as a positional argument, which is what stops it being read as a /// command, but nothing there stops `../..` from naming a file outside the /// models directory. Rejected rather than rewritten, since a silently /// corrected path downloads the right bytes to the wrong place. fn checked(key: &str) -> Result<()> { let bad = |why: &str| anyhow::anyhow!("\"{key}\" is not a model this can store: {why}"); for part in key.split('/') { if part.is_empty() || part == "." || part == ".." { return Err(bad( "a path component that would leave the models directory", )); } if part.contains('\\') || part.chars().any(char::is_control) { return Err(bad("a path component with a character no file here has")); } } let Some((_, file)) = key.rsplit_once('/') else { return Err(bad("a key is owner/repo/file.gguf")); }; if !file.ends_with(".gguf") { return Err(bad("only .gguf files are models")); } Ok(()) } pub fn key_for(repo: &str, file: &str) -> String { format!("{repo}/{file}") } /// Expands a leading `~` the way that machine's own shell would, which is the /// only place that knows what it stands for. Prefixed to every script here; /// each takes the directory as `$1`. const EXPAND: &str = "d=$1; case $d in \"~\") d=$HOME;; \"~/\"*) d=$HOME/${d#\"~/\"};; esac;"; /// The download itself, run detached on the machine that will hold the file. /// /// Passed to [`START`] as an argument rather than written inside it, so that /// neither script has to quote the other: `sh -c "$worker"` takes this whole /// text as one string the outer shell never parses. /// /// `-sS` rather than plain: curl's progress meter goes to stderr, and the /// same file is what a failure is read out of -- a message buried in three /// screens of redrawn bar is one the phone cannot show. Progress is read off /// the partial file instead, which is the measurement rather than a report /// of it. /// /// The state file is written whole and moved into place, because a listing /// can arrive in the middle of any of these writes and a truncated one is a /// download that blinks out of the phone's list for a poll. /// /// `curl` is waited on rather than run in the foreground, because a cancel /// arrives as a signal and a shell blocked in `wait` is the one that can run /// a trap for it. The trap writes nothing when the partial has already gone: /// that is how [`REMOVE`] deletes a running download without the dying worker /// recreating the state file behind it. const WORKER: &str = r#" t=$1; url=$2; sha=$3; total=$4; p=$t.part; s=$t.download; e=$t.part.err st() { printf 'pid=%s\nsha=%s\ntotal=%s\nstate=%s\nerror=%s\n' "$$" "$sha" "$total" "$1" "$2" > "$s.tmp" && mv "$s.tmp" "$s"; } trap '[ -n "$c" ] && kill "$c" 2>/dev/null; [ -f "$p" ] && st cancelled ""; exit 0' TERM INT c= st running "" have=$(wc -c 2>/dev/null < "$p" | tr -d " ") if [ -z "$total" ] || [ "$have" != "$total" ]; then curl -fsSL --retry 3 --retry-delay 2 -C - -o "$p" "$url" 2> "$e" & c=$! if ! wait "$c"; then st failed "$(tr -s '\n\t\r' ' ' < "$e" | tail -c 300)" exit 1 fi fi rm -f "$e" if [ -n "$sha" ] && command -v sha256sum > /dev/null 2>&1; then st verifying "" got=$(sha256sum "$p" | cut -d' ' -f1) if [ "$got" != "$sha" ]; then rm -f "$p" st failed "it arrived corrupted -- HuggingFace publishes sha256 $sha, what arrived hashes to $got. It has been deleted; downloading again starts clean." exit 1 fi fi mv "$p" "$t" && rm -f "$s" "#; /// Starts [`WORKER`], or leaves alone whatever is already happening. /// /// Idempotent on purpose, in both directions: a phone that lost its /// connection presses the button again, and that must join the run already /// going rather than start a second one writing the same file. A model /// already downloaded is likewise the answer rather than an error, which is /// the ordinary case once two devices can both see the list. const START: &str = r#" k=$2; url=$3; sha=$4; total=$5; worker=$6 t=$d/$k; s=$t.download [ -f "$t" ] && exit 0 pid=$(sed -n 's/^pid=//p' "$s" 2>/dev/null) if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then exit 0; fi command -v curl > /dev/null 2>&1 || { echo "there is no curl on this machine to download with" >&2; exit 1; } mkdir -p "${t%/*}" || { echo "cannot make ${t%/*} to download into" >&2; exit 1; } had=$(sed -n 's/^sha=//p' "$s" 2>/dev/null) if [ -z "$sha" ] || [ "$had" != "$sha" ]; then rm -f "$t.part"; fi nohup sh -c "$worker" sh "$t" "$url" "$sha" "$total" > /dev/null 2>&1 & "#; /// Every download the machine is holding state for, with the bytes read off /// the partial file rather than from anything remembered here. /// /// `kill -0` is why a stopped machine does not leave a download claiming to /// be running for ever: a state file whose process is gone is the failure it /// actually is. /// /// A state file beside a finished model is skipped, because the file is the /// truth and the state file is only ever a report about producing it. That is /// also what keeps the one race here self-correcting: two devices asking for /// the same model in the same second get two workers writing one partial, /// whose sha will not match -- and whichever of them loses the rename would /// otherwise leave a failure recorded against a model that is sitting there. const LIST: &str = r#" [ -d "$d" ] || exit 0 cd "$d" || exit 0 find . -type f -name '*.download' | while read -r f; do k=${f#./}; k=${k%.download} [ -f "$k" ] && continue size=$(wc -c 2>/dev/null < "$k.part" | tr -d " ") [ -n "$size" ] || size=0 pid=$(sed -n 's/^pid=//p' "$f") alive=no if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=yes; fi printf '%s\037%s\037%s\037%s\000' "$size" "$alive" "$(tr '\n' '\036' < "$f")" "$k" done "#; /// Asks a running download to stop; the partial stays, so starting again /// resumes rather than refetching. /// /// A record whose process has already gone is written off here rather than /// left to be reported as a failure, because somebody asking for it to stop /// and it being stopped is not a fault. const CANCEL: &str = r#" k=$2; s=$d/$k.download pid=$(sed -n 's/^pid=//p' "$s" 2>/dev/null) [ -n "$pid" ] || { echo "nothing is downloading $k" >&2; exit 1; } if ! kill -TERM "$pid" 2>/dev/null; then sed 's/^state=.*/state=cancelled/' "$s" > "$s.tmp" && mv "$s.tmp" "$s" fi "#; /// Takes a model off a machine: the file, a partial, and the state beside it. /// /// The partial goes *before* the signal, so that a worker dying from it finds /// nothing to write about and leaves no state file behind -- see [`WORKER`]'s /// trap. const REMOVE: &str = r#" k=$2; t=$d/$k if [ ! -f "$t" ] && [ ! -f "$t.part" ] && [ ! -f "$t.download" ]; then echo "$k is not on this machine" >&2 exit 1 fi rm -f "$t.part" pid=$(sed -n 's/^pid=//p' "$t.download" 2>/dev/null) [ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null rm -f "$t" "$t.part.err" "$t.download" "$t.download.tmp" # The repository's own directory, and only that one: it was made by the # download and is litter once empty. Never its parent, which for a key with # one component would be the models directory itself. rmdir "${t%/*}" 2>/dev/null exit 0 "#; /// Runs one of the scripts above on `transport`, against `dir`. async fn run(transport: &Transport, script: &str, dir: &str, rest: &[&str]) -> Result { let mut args = vec![ "-c".to_string(), format!("{EXPAND}{script}"), "sh".to_string(), dir.to_string(), ]; args.extend(rest.iter().map(|arg| (*arg).to_string())); transport.capture(&Launch::new("sh", args, None)).await } /// Starts fetching `file` from `repo` onto the machine `transport` reaches. pub async fn start( transport: &Transport, dir: &str, repo: &str, file: &str, published: &Published, ) -> Result<()> { let key = key_for(repo, file); checked(&key)?; let url = format!( "https://huggingface.co/{repo}/resolve/main/{}", file.replace(' ', "%20") ); run( transport, START, dir, &[ &key, &url, published.sha256.as_deref().unwrap_or(""), &published.bytes.map(|b| b.to_string()).unwrap_or_default(), WORKER, ], ) .await?; Ok(()) } /// Every download in flight or stopped on that machine, in key order -- /// which is the order the models they become are listed in. pub async fn downloads(transport: &Transport, dir: &str) -> Result> { let out = run(transport, LIST, dir, &[]).await?; let mut found: Vec = out .split('\0') .filter(|record| !record.is_empty()) // The key last, so a name with a separator in it cannot eat a field, // and the state file's own lines separated by RS inside field three. .filter_map(|record| { let mut fields = record.splitn(4, '\u{1f}'); let done: u64 = fields.next()?.trim().parse().unwrap_or(0); let alive = fields.next()? == "yes"; let status: HashMap<&str, &str> = fields .next()? .split('\u{1e}') .filter_map(|line| line.split_once('=')) .collect(); let key = fields.next()?.trim().to_string(); let (repo, file) = key.rsplit_once('/')?; let recorded = status .get("state") .and_then(|word| DownloadState::from_word(word.trim())); let error = status .get("error") .map(|text| text.trim()) .filter(|text| !text.is_empty()) .map(str::to_string); // A word this server does not know means a state file written by // a different version of the script. Whether there is a process // behind it is measured either way, so that is what it is // reported as -- dropping the record instead would make a // download that is using disk invisible. let recorded = recorded.unwrap_or(if alive { DownloadState::Running } else { DownloadState::Failed }); // A record that says it is working and has no process behind it // is the one case this has to correct: the machine was restarted, // or the worker was killed by something other than a cancel. let stopped = matches!(recorded, DownloadState::Running | DownloadState::Verifying); Some(Download { state: if stopped && !alive { DownloadState::Failed } else { recorded }, error: error.or_else(|| { (stopped && !alive).then(|| { "it stopped before it finished -- the machine may have been restarted. \ Downloading again carries on from here." .to_string() }) }), done, total: status.get("total").and_then(|t| t.trim().parse().ok()), repo: repo.to_string(), file: file.to_string(), key, }) }) .collect(); found.sort_by(|a, b| a.key.cmp(&b.key)); Ok(found) } /// Asks the machine to stop one download. The partial stays behind. pub async fn cancel(transport: &Transport, dir: &str, key: &str) -> Result<()> { checked(key)?; run(transport, CANCEL, dir, &[key]).await?; Ok(()) } /// Removes a model from a machine, downloaded or half-downloaded. pub async fn remove(transport: &Transport, dir: &str, key: &str) -> Result<()> { checked(key)?; run(transport, REMOVE, dir, &[key]).await?; Ok(()) } /// Where a machine reached over ssh keeps its models, when its machine does /// not say. /// /// The same place this backend puts its own downloads, written out rather /// than derived: `$XDG_DATA_HOME` here describes *this* machine's /// environment, and the far machine's is the far machine's business. A /// machine whose models are elsewhere says so (`SshConfig::models_dir`). const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models"; /// Which directory holds the models on the machine `transport` reaches. /// /// One answer, because two things ask: the list a spawn screen offers, /// and the path a session hands `llama-server`. A machine that listed one /// directory and served from another would offer models that then failed /// to load, which reads as the model being broken. pub fn dir_on(transport: &Transport, local: &Path) -> String { match transport { Transport::Here => local.to_string_lossy().into_owned(), Transport::Ssh { ssh, .. } => ssh .models_dir .as_ref() .map_or(FAR_MODELS_DIR.to_string(), |dir| { dir.to_string_lossy().into_owned() }), } } /// Every GGUF on a configured machine, which is the machine that /// would have to serve it. /// /// The local half of this is [`ModelStore::list`], reading the same shape /// off this machine's disk; a caller picks by transport, since a machine /// with no ssh *is* this machine and asking a shell about it would be a /// slower way to the same answer. What must not happen is offering this /// backend's downloads for a session on another machine: the file has to /// be where `llama-server` runs, and a list that says otherwise is a /// claim about the wrong filesystem. /// /// `dir` is that machine's models directory, `~` included -- expanded on /// the far side, which is the only place that knows what it is. A /// directory that is not there is an empty list rather than a failure: a /// machine that has never had a model put on it is an ordinary state, and /// the same one as a machine whose directory exists and is empty. /// /// Each record carries the head of the file as well as its size, because a /// model's own name is inside it (see [`crate::gguf`]) and the file is on the /// far machine. The alternative is a second round trip per model, or naming /// remote models by path while local ones get their proper names -- one /// machine's models reading differently from another's is exactly the /// confusion the name was added to remove. The prefix is bounded at /// [`crate::gguf::PREFIX_BYTES`], which is what keeps this one round trip's /// worth of bytes. pub async fn on_machine(transport: &Transport, dir: &str) -> Result> { let script = format!( "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${{p#\"~/\"}};; esac; \ [ -d \"$p\" ] || exit 0; cd \"$p\" || exit 0; \ find . -type f -name '*.gguf' -exec sh -c '\ for f do printf \"%s\\t%s\\t%s\\0\" \"$(wc -c < \"$f\")\" \ \"$(head -c {prefix} \"$f\" | base64 | tr -d \"\\n\")\" \"${{f#./}}\"; done\ ' sh {{}} +", prefix = crate::gguf::PREFIX_BYTES, ); let launch = Launch::new( "sh", vec!["-c".to_string(), script, "sh".to_string(), dir.to_string()], None, ); let out = transport.capture(&launch).await?; let mut found: Vec = out .split('\0') .filter(|record| !record.is_empty()) // Three fields, and the name last, so a `\t` in a filename survives. // The middle one is base64, which has no tab in its alphabet. .filter_map(|record| { let (bytes, rest) = record.split_once('\t')?; let (head, key) = rest.split_once('\t')?; let (repo, file) = key.rsplit_once('/')?; Some(LocalModel { key: key.to_string(), repo: repo.to_string(), file: file.to_string(), bytes: bytes.trim().parse().unwrap_or(0), name: name_in_prefix(head), }) }) .collect(); found.sort_by(|a, b| a.key.cmp(&b.key)); Ok(found) } /// The model's name out of a base64 prefix of its file. /// /// The remote half of [`name_of`], and `None` for everything that half /// answers `None` for, plus a prefix that did not survive the trip. fn name_in_prefix(head: &str) -> Option { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD .decode(head.trim()) .ok()?; crate::gguf::name(&mut bytes.as_slice()) } /// A model repository on HuggingFace, as the browse screen shows it. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoteRepo { /// `owner/name`, which is what everything else here is keyed by. pub id: String, pub downloads: u64, pub likes: u64, } /// One downloadable file inside a repository. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoteFile { pub path: String, pub bytes: u64, /// Already on the machine being looked at, so the phone can say so rather /// than offering to fetch it again. pub have: bool, } /// Searches HuggingFace for GGUF repositories matching `query`. /// /// Proxied through this server rather than called from the phone, for two /// reasons that both matter: the app trusts exactly one certificate -- this /// server's -- and has no general internet trust to spend on huggingface.co, /// and the machine that has to do the downloading is this one. pub fn search(query: &str) -> Result> { let url = format!( "https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1", urlencode(query) ); let body = get_json(&url)?; let list = body .as_array() .context("HuggingFace returned something that is not a list")?; Ok(list .iter() .filter_map(|m| { Some(RemoteRepo { id: m.get("id")?.as_str()?.to_string(), downloads: m .get("downloads") .and_then(serde_json::Value::as_u64) .unwrap_or(0), likes: m .get("likes") .and_then(serde_json::Value::as_u64) .unwrap_or(0), }) }) .collect()) } /// Asks HuggingFace for both halves of [`Published`] in the one call they /// come from. pub fn published(repo: &str, file: &str) -> Result { let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true"); let body = get_json(&url)?; let entry = body .as_array() .and_then(|files| { files .iter() .find(|f| f.get("path").and_then(serde_json::Value::as_str) == Some(file)) }) .with_context(|| format!("{repo} does not have a file called {file}"))?; Ok(Published { bytes: entry.get("size").and_then(serde_json::Value::as_u64), sha256: entry .get("lfs") .and_then(|lfs| lfs.get("oid")) .and_then(serde_json::Value::as_str) .map(str::to_string), }) } /// The GGUF files in one repository, largest last, with the ones already /// downloaded marked. pub fn files(repo: &str, have: &std::collections::HashSet) -> Result> { let url = format!("https://huggingface.co/api/models/{repo}/tree/main"); let body = get_json(&url)?; let list = body .as_array() .context("HuggingFace returned something that is not a list")?; let mut files: Vec = list .iter() .filter_map(|f| { let path = f.get("path")?.as_str()?.to_string(); if !path.ends_with(".gguf") { return None; } Some(RemoteFile { bytes: f .get("size") .and_then(serde_json::Value::as_u64) .unwrap_or(0), have: have.contains(&key_for(repo, &path)), path, }) }) .collect(); files.sort_by_key(|f| f.bytes); Ok(files) } fn get_json(url: &str) -> Result { let text = ureq::get(url) .header("User-Agent", USER_AGENT) .call() .and_then(|mut r| r.body_mut().read_to_string()) .with_context(|| format!("GET {url}"))?; serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON")) } /// Percent-encodes a query string. Deliberately minimal -- this escapes what a /// model search and a model key actually contain rather than implementing the /// whole rule set, and anything unexpected becomes `%XX` rather than being /// passed through. pub fn urlencode(value: &str) -> String { value .bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { (b as char).to_string() } b' ' => "+".to_string(), other => format!("%{other:02X}"), }) .collect() } #[cfg(test)] mod tests { use super::*; fn model(repo: &str, file: &str, name: Option<&str>) -> LocalModel { LocalModel { key: format!("{repo}/{file}"), repo: repo.to_string(), file: file.to_string(), bytes: 1, name: name.map(str::to_string), } } /// The cascade, and the one rung that is not simply "is it unique": a name /// the file it came from says nothing about is the converter's working /// directory rather than the model's name. #[test] fn a_model_is_labelled_by_the_first_thing_that_identifies_it() { let models = [ // Its own name, which is what a reader recognises. model( "Qwen/Qwen3-0.6B-GGUF", "Qwen3-0.6B-Q8_0.gguf", Some("Qwen3-0.6B"), ), // Two quantisations carry one name, so both fall to the file. model("org/Big-GGUF", "Big-Q4_K_M.gguf", Some("Big")), model("org/Big-GGUF", "Big-Q8_0.gguf", Some("Big")), // `convert_hf_to_gguf.py` naming the directory it converted. model("PrismML/Bonsai-GGUF", "Bonsai-1B-TQ1_0.gguf", Some("hf")), // Nothing to go on but where it came from. model("org/Quiet-GGUF", "weights.gguf", None), ]; assert_eq!( labels(&models), [ "Qwen3-0.6B", "Big-Q4_K_M", "Big-Q8_0", "Bonsai-1B-TQ1_0", "weights", ], ); } /// Two repos holding a file of the same name: the file rung collides as /// well, and the key is what always terminates the cascade. #[test] fn a_file_name_two_repos_share_falls_through_to_the_key() { let models = [ model("one/GGUF", "model.gguf", None), model("two/GGUF", "model.gguf", None), ]; assert_eq!( labels(&models), ["one/GGUF/model.gguf", "two/GGUF/model.gguf"] ); } }