# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs
723 lines
27 KiB
Rust
723 lines
27 KiB
Rust
//! GGUF models on this machine, and the downloads that produce them.
|
|
//!
|
|
//! The registry pattern again: one owner, one lock, so what is on disk and what
|
|
//! this server believes cannot come apart. Three things shape the design, all
|
|
//! consequences of a model file being gigabytes rather than kilobytes:
|
|
//!
|
|
//! **A download belongs to the model, not to whoever asked for it.** It is
|
|
//! keyed by the model it produces and lives here, so any device can watch it --
|
|
//! including one that did not start it. State in a per-connection channel would
|
|
//! not survive the phone locking its screen, which for an hour-long download is
|
|
//! the normal case.
|
|
//!
|
|
//! **Every run has an id, and its outcome outlives it.** Without those, "not
|
|
//! downloading" is three answers at once -- it finished, it never started, or a
|
|
//! different run finished while you were away.
|
|
//!
|
|
//! **Progress is measured, never estimated.** `total` is whatever
|
|
//! `Content-Length` said and nothing else; when the server does not send one it
|
|
//! stays `None` and the phone shows that it does not know.
|
|
|
|
use std::collections::HashMap;
|
|
use std::io::{Read, Seek, SeekFrom, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use serde::Serialize;
|
|
|
|
use wg_app_link::private;
|
|
|
|
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"));
|
|
|
|
/// Read size per loop iteration. Big enough that the syscall overhead is
|
|
/// nothing against a multi-gigabyte file, small enough that a cancel is noticed
|
|
/// promptly -- the flag is only checked between chunks.
|
|
const CHUNK: usize = 256 * 1024;
|
|
|
|
/// A model file sitting on this machine, ready to run.
|
|
#[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 a run is doing, or did. Flat rather than a tagged enum carrying its
|
|
/// message, because the phone switches on this and a string it can compare is
|
|
/// easier to render than a variant it has to destructure.
|
|
#[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, rather than a bar
|
|
/// sitting at 100% for half a minute.
|
|
Verifying,
|
|
Finished,
|
|
Failed,
|
|
Cancelled,
|
|
}
|
|
|
|
/// One download run, as the phone sees it.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DownloadStatus {
|
|
pub key: String,
|
|
/// Distinguishes this run from any earlier one for the same model, so a
|
|
/// device that was watching run 3 can tell it is now looking at run 4.
|
|
pub run: u64,
|
|
pub repo: String,
|
|
pub file: String,
|
|
pub state: DownloadState,
|
|
pub done: u64,
|
|
/// What `Content-Length` said, or absent when the server did not say.
|
|
/// Absent means "unknown", never "zero".
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub total: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
pub started: f64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub finished: Option<f64>,
|
|
}
|
|
|
|
/// The mutable half of a run, behind one lock.
|
|
#[derive(Debug)]
|
|
struct Progress {
|
|
state: DownloadState,
|
|
done: u64,
|
|
total: Option<u64>,
|
|
error: Option<String>,
|
|
started: f64,
|
|
finished: Option<f64>,
|
|
}
|
|
|
|
/// A run, shared between the thread doing the work and everyone watching.
|
|
struct Run {
|
|
id: u64,
|
|
key: String,
|
|
repo: String,
|
|
file: String,
|
|
progress: Mutex<Progress>,
|
|
/// Set by [`ModelStore::cancel`]; the download loop checks it between
|
|
/// chunks and stops, leaving the partial file for a later resume.
|
|
cancel: AtomicBool,
|
|
}
|
|
|
|
impl Run {
|
|
fn status(&self) -> DownloadStatus {
|
|
let p = self.progress.lock().unwrap();
|
|
DownloadStatus {
|
|
key: self.key.clone(),
|
|
run: self.id,
|
|
repo: self.repo.clone(),
|
|
file: self.file.clone(),
|
|
state: p.state,
|
|
done: p.done,
|
|
total: p.total,
|
|
error: p.error.clone(),
|
|
started: p.started,
|
|
finished: p.finished,
|
|
}
|
|
}
|
|
|
|
fn finish(&self, state: DownloadState, error: Option<String>) {
|
|
let mut p = self.progress.lock().unwrap();
|
|
p.state = state;
|
|
p.error = error;
|
|
p.finished = Some(crate::session::now());
|
|
}
|
|
}
|
|
|
|
/// Every model this machine has, and every download in flight or finished.
|
|
pub struct ModelStore {
|
|
dir: PathBuf,
|
|
/// Keyed by model key: one run per model at a time, and the last run for a
|
|
/// model stays here after it ends so its outcome can still be read.
|
|
runs: Mutex<HashMap<String, Arc<Run>>>,
|
|
next_run: AtomicU64,
|
|
}
|
|
|
|
impl ModelStore {
|
|
pub fn new(dir: PathBuf) -> Self {
|
|
Self {
|
|
dir,
|
|
runs: Mutex::new(HashMap::new()),
|
|
next_run: AtomicU64::new(1),
|
|
}
|
|
}
|
|
|
|
/// Where a model's file lives, refusing anything that would escape the
|
|
/// models directory.
|
|
///
|
|
/// The repo and file come from a phone, so they are treated as hostile:
|
|
/// every component must be an ordinary name. Rejecting rather than
|
|
/// sanitising, since a silently rewritten path would download the right
|
|
/// bytes to the wrong place.
|
|
fn path_for(&self, repo: &str, file: &str) -> Result<PathBuf> {
|
|
let mut path = self.dir.clone();
|
|
for part in repo.split('/').chain(file.split('/')) {
|
|
if part.is_empty() || part == "." || part == ".." || part.contains('\\') {
|
|
bail!("\"{repo}/{file}\" is not a name this can store: \"{part}\"");
|
|
}
|
|
path.push(part);
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
pub fn key_for(repo: &str, file: &str) -> String {
|
|
format!("{repo}/{file}")
|
|
}
|
|
|
|
/// Every `.gguf` found under the models directory, newest first. Read from
|
|
/// disk on each call rather than cached: a file deleted by hand should stop
|
|
/// being offered.
|
|
pub fn list(&self) -> Vec<LocalModel> {
|
|
let mut found = Vec::new();
|
|
collect(&self.dir, &self.dir, &mut found);
|
|
found.sort_by(|a, b| a.key.cmp(&b.key));
|
|
found
|
|
}
|
|
|
|
/// The status of every run this server remembers.
|
|
pub fn downloads(&self) -> Vec<DownloadStatus> {
|
|
let runs = self.runs.lock().unwrap();
|
|
let mut all: Vec<_> = runs.values().map(|run| run.status()).collect();
|
|
all.sort_by_key(|status| std::cmp::Reverse(status.run));
|
|
all
|
|
}
|
|
|
|
/// Starts fetching `file` from `repo`, or returns the run already doing so.
|
|
/// Idempotent on purpose: a phone that lost its connection will press the
|
|
/// button again, and that must join the existing run rather than start a
|
|
/// second one writing the same file.
|
|
pub fn start(self: &Arc<Self>, repo: &str, file: &str) -> Result<DownloadStatus> {
|
|
let key = Self::key_for(repo, file);
|
|
let target = self.path_for(repo, file)?;
|
|
if target.is_file() {
|
|
bail!("{key} is already downloaded");
|
|
}
|
|
|
|
let mut runs = self.runs.lock().unwrap();
|
|
if let Some(existing) = runs.get(&key)
|
|
&& existing.progress.lock().unwrap().state == DownloadState::Running
|
|
{
|
|
return Ok(existing.status());
|
|
}
|
|
|
|
let run = Arc::new(Run {
|
|
id: self.next_run.fetch_add(1, Ordering::Relaxed),
|
|
key: key.clone(),
|
|
repo: repo.to_string(),
|
|
file: file.to_string(),
|
|
progress: Mutex::new(Progress {
|
|
state: DownloadState::Running,
|
|
done: 0,
|
|
total: None,
|
|
error: None,
|
|
started: crate::session::now(),
|
|
finished: None,
|
|
}),
|
|
cancel: AtomicBool::new(false),
|
|
});
|
|
runs.insert(key, Arc::clone(&run));
|
|
let status = run.status();
|
|
drop(runs);
|
|
|
|
// A dedicated thread rather than the blocking pool: this holds its
|
|
// thread for as long as the download takes, which is minutes to hours,
|
|
// and the pool exists for short work.
|
|
let store = Arc::clone(self);
|
|
std::thread::spawn(move || {
|
|
let outcome = store.fetch(&run, &target);
|
|
match outcome {
|
|
Ok(()) if run.cancel.load(Ordering::Relaxed) => {
|
|
run.finish(DownloadState::Cancelled, None);
|
|
tracing::info!("download {} cancelled", run.key);
|
|
}
|
|
Ok(()) => {
|
|
run.finish(DownloadState::Finished, None);
|
|
tracing::info!("download {} finished", run.key);
|
|
}
|
|
Err(err) => {
|
|
let message = format!("{err:#}");
|
|
tracing::warn!("download {} failed: {message}", run.key);
|
|
run.finish(DownloadState::Failed, Some(message));
|
|
}
|
|
}
|
|
});
|
|
Ok(status)
|
|
}
|
|
|
|
/// Asks a running download to stop. The partial file stays, so starting
|
|
/// again resumes rather than refetching.
|
|
pub fn cancel(&self, key: &str) -> Result<DownloadStatus> {
|
|
let runs = self.runs.lock().unwrap();
|
|
let Some(run) = runs.get(key) else {
|
|
bail!("no download for {key}");
|
|
};
|
|
run.cancel.store(true, Ordering::Relaxed);
|
|
Ok(run.status())
|
|
}
|
|
|
|
/// Removes a downloaded model, and any partial file for it.
|
|
pub fn delete(&self, key: &str) -> Result<()> {
|
|
let (repo, file) = key.rsplit_once('/').context("a key is repo/file")?;
|
|
let target = self.path_for(repo, file)?;
|
|
let partial = partial_of(&target);
|
|
if !target.is_file() && !partial.is_file() {
|
|
bail!("{key} is not downloaded");
|
|
}
|
|
for path in [&target, &partial] {
|
|
if path.is_file() {
|
|
std::fs::remove_file(path).with_context(|| format!("remove {}", path.display()))?;
|
|
}
|
|
}
|
|
self.runs.lock().unwrap().remove(key);
|
|
Ok(())
|
|
}
|
|
|
|
fn fetch(&self, run: &Run, target: &Path) -> Result<()> {
|
|
let partial = partial_of(target);
|
|
let identity = identity_of(target);
|
|
if let Some(parent) = target.parent() {
|
|
private::create_dir(parent)?;
|
|
}
|
|
|
|
// What we have, and what it was part of. A partial with no recorded
|
|
// identity is not resumable -- it could be a fragment of any revision.
|
|
let known = std::fs::read_to_string(&identity)
|
|
.ok()
|
|
.map(|s| s.trim().to_string());
|
|
let have = match known {
|
|
Some(_) => partial.metadata().map(|m| m.len()).unwrap_or(0),
|
|
None => 0,
|
|
};
|
|
|
|
let url = format!(
|
|
"https://huggingface.co/{}/resolve/main/{}",
|
|
run.repo,
|
|
run.file.replace(' ', "%20")
|
|
);
|
|
let (mut response, mut resumed) = request(&url, have)?;
|
|
let mut etag = etag_of(&response);
|
|
|
|
// HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a
|
|
// deliberately stale validator still answers 206 with the ranged bytes.
|
|
// So the header cannot be relied on to restart us, and the check is done
|
|
// here instead: if what arrived is not the revision our partial belongs
|
|
// to, resuming would splice two files into something of exactly the
|
|
// right length and the wrong contents.
|
|
if resumed && etag.is_some() && etag != known {
|
|
tracing::info!(
|
|
"{} changed upstream since the partial was written -- starting again",
|
|
run.key,
|
|
);
|
|
let (fresh, fresh_resumed) = request(&url, 0)?;
|
|
response = fresh;
|
|
resumed = fresh_resumed;
|
|
etag = etag_of(&response);
|
|
}
|
|
|
|
// On a 206, Content-Length is the length of the *range*, not of the file
|
|
// -- taken at face value it would fill the bar at 72 MB of a 234 MB
|
|
// model. The whole size is the last field of Content-Range, which has
|
|
// the further merit of not depending on where the range began.
|
|
let total: Option<u64> = if resumed {
|
|
response
|
|
.headers()
|
|
.get("content-range")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| {
|
|
v.rsplit_once('/')
|
|
.map(|(_, whole)| whole.trim().to_string())
|
|
})
|
|
.and_then(|whole| whole.parse().ok())
|
|
} else {
|
|
response
|
|
.headers()
|
|
.get("content-length")
|
|
.and_then(|v| v.to_str().ok()?.parse().ok())
|
|
};
|
|
let mut done = if resumed { have } else { 0 };
|
|
{
|
|
let mut p = run.progress.lock().unwrap();
|
|
p.done = done;
|
|
p.total = total;
|
|
}
|
|
|
|
// `truncate(false)` is the whole resume story: the file is opened to be
|
|
// seeked into and appended to, and truncating would throw away exactly
|
|
// the bytes the Range request just asked the server not to send again.
|
|
// Stated rather than left to the default.
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(false)
|
|
.open(&partial)
|
|
.with_context(|| format!("open {}", partial.display()))?;
|
|
if resumed {
|
|
file.seek(SeekFrom::Start(have))
|
|
.context("seek to resume point")?;
|
|
} else {
|
|
file.set_len(0)
|
|
.context("truncate a partial we cannot resume onto")?;
|
|
}
|
|
// Written before the body, so an interrupted download leaves a partial
|
|
// that can still say which revision it belongs to.
|
|
if let Some(etag) = &etag {
|
|
std::fs::write(&identity, etag).ok();
|
|
}
|
|
|
|
let mut reader = response.body_mut().as_reader();
|
|
let mut buffer = vec![0u8; CHUNK];
|
|
loop {
|
|
if run.cancel.load(Ordering::Relaxed) {
|
|
file.flush().ok();
|
|
return Ok(());
|
|
}
|
|
let read = reader
|
|
.read(&mut buffer)
|
|
.context("reading from HuggingFace")?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
file.write_all(&buffer[..read])
|
|
.context("writing the model file")?;
|
|
done += read as u64;
|
|
run.progress.lock().unwrap().done = done;
|
|
}
|
|
file.flush().context("flushing the model file")?;
|
|
drop(file);
|
|
|
|
// Checked before the rename, so a file that fails never gets the real
|
|
// name and `list` never offers it. With the identity check above this
|
|
// should not fire; it is here because a wrong model is the kind of
|
|
// failure that surfaces as bad output rather than as an error.
|
|
if let Some(expected) = published_sha256(&run.repo, &run.file) {
|
|
run.progress.lock().unwrap().state = DownloadState::Verifying;
|
|
let actual = sha256_of(&partial)?;
|
|
if actual != expected {
|
|
std::fs::remove_file(&partial).ok();
|
|
std::fs::remove_file(&identity).ok();
|
|
bail!(
|
|
"{} arrived corrupted -- HuggingFace publishes sha256 {expected}, what \
|
|
arrived hashes to {actual}. It has been deleted; downloading again \
|
|
starts clean.",
|
|
run.key,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Renamed only once complete, so a file at its real name is always a
|
|
// whole model -- `list` needs no other way to tell.
|
|
std::fs::rename(&partial, target)
|
|
.with_context(|| format!("finish {}", target.display()))?;
|
|
std::fs::remove_file(&identity).ok();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// The sha256 of a file, read in chunks -- these are gigabytes, and reading one
|
|
/// into memory to hash it would be the largest allocation this server makes.
|
|
fn sha256_of(path: &Path) -> Result<String> {
|
|
use sha2::{Digest, Sha256};
|
|
let mut file =
|
|
std::fs::File::open(path).with_context(|| format!("reopen {}", path.display()))?;
|
|
let mut hasher = Sha256::new();
|
|
let mut buffer = vec![0u8; CHUNK];
|
|
loop {
|
|
let read = file.read(&mut buffer).context("reading back to verify")?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
hasher.update(&buffer[..read]);
|
|
}
|
|
// Hex by hand, as `wg_app_link::enroll::token_hash_hex` also has to, since
|
|
// this sha2 version's output type does not implement LowerHex.
|
|
Ok(hasher
|
|
.finalize()
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect())
|
|
}
|
|
|
|
/// One GET, ranged when there is something to resume onto.
|
|
fn request(url: &str, from: u64) -> Result<(ureq::http::Response<ureq::Body>, bool)> {
|
|
let mut get = ureq::get(url).header("User-Agent", USER_AGENT);
|
|
if from > 0 {
|
|
get = get.header("Range", &format!("bytes={from}-"));
|
|
}
|
|
let response = get.call().with_context(|| format!("GET {url}"))?;
|
|
// Trust the status, not the request: a server that ignores Range answers
|
|
// 200 with the whole file, and appending to that would corrupt it.
|
|
let resumed = response.status() == 206;
|
|
Ok((response, resumed))
|
|
}
|
|
|
|
fn etag_of(response: &ureq::http::Response<ureq::Body>) -> Option<String> {
|
|
Some(
|
|
response
|
|
.headers()
|
|
.get("etag")?
|
|
.to_str()
|
|
.ok()?
|
|
.trim()
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial beside it
|
|
/// is a piece of.
|
|
fn identity_of(target: &Path) -> PathBuf {
|
|
let mut name = target.as_os_str().to_os_string();
|
|
name.push(".part.etag");
|
|
PathBuf::from(name)
|
|
}
|
|
|
|
/// `x.gguf` -> `x.gguf.part`, the in-progress name.
|
|
fn partial_of(target: &Path) -> PathBuf {
|
|
let mut name = target.as_os_str().to_os_string();
|
|
name.push(".part");
|
|
PathBuf::from(name)
|
|
}
|
|
|
|
/// Walks `dir` collecting `.gguf` files, keyed by their path under `root`.
|
|
fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
collect(root, &path, found);
|
|
continue;
|
|
}
|
|
if path.extension().is_none_or(|e| e != "gguf") {
|
|
continue;
|
|
}
|
|
let Ok(relative) = path.strip_prefix(root) else {
|
|
continue;
|
|
};
|
|
let key = relative.to_string_lossy().replace('\\', "/");
|
|
let Some((repo, file)) = key.rsplit_once('/') else {
|
|
continue;
|
|
};
|
|
found.push(LocalModel {
|
|
key: key.clone(),
|
|
repo: repo.to_string(),
|
|
file: file.to_string(),
|
|
bytes: entry.metadata().map(|m| m.len()).unwrap_or(0),
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Where a machine reached over ssh keeps its models, when its setup 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
|
|
/// setup 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 the machine a setup names, 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 setup
|
|
/// 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.
|
|
pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalModel>> {
|
|
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
|
|
[ -d \"$p\" ] || exit 0; \
|
|
find \"$p\" -type f -name '*.gguf' -printf '%s\\t%P\\0'";
|
|
let launch = Launch::new(
|
|
"sh",
|
|
vec![
|
|
"-c".to_string(),
|
|
script.to_string(),
|
|
"sh".to_string(),
|
|
dir.to_string(),
|
|
],
|
|
None,
|
|
);
|
|
let out = transport.capture(&launch).await?;
|
|
let mut found: Vec<LocalModel> = out
|
|
.split('\0')
|
|
.filter(|record| !record.is_empty())
|
|
// Two fields, and the name last, so a `\t` in a filename survives.
|
|
.filter_map(|record| record.split_once('\t'))
|
|
.filter_map(|(bytes, key)| {
|
|
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),
|
|
})
|
|
})
|
|
.collect();
|
|
found.sort_by(|a, b| a.key.cmp(&b.key));
|
|
Ok(found)
|
|
}
|
|
|
|
/// 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 this machine, 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<Vec<RemoteRepo>> {
|
|
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())
|
|
}
|
|
|
|
/// The sha256 HuggingFace publishes for one file, if it publishes one. It is
|
|
/// the LFS object id, which for these repositories is the sha256 of the content
|
|
/// -- so it is a free integrity check rather than a second source of truth.
|
|
fn published_sha256(repo: &str, file: &str) -> Option<String> {
|
|
let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true");
|
|
let body = get_json(&url).ok()?;
|
|
body.as_array()?.iter().find_map(|f| {
|
|
(f.get("path")?.as_str()? == file)
|
|
.then(|| f.get("lfs")?.get("oid")?.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, store: &ModelStore) -> Result<Vec<RemoteFile>> {
|
|
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 have: std::collections::HashSet<String> = store.list().into_iter().map(|m| m.key).collect();
|
|
let mut files: Vec<RemoteFile> = 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(&ModelStore::key_for(repo, &path)),
|
|
path,
|
|
})
|
|
})
|
|
.collect();
|
|
files.sort_by_key(|f| f.bytes);
|
|
Ok(files)
|
|
}
|
|
|
|
fn get_json(url: &str) -> Result<serde_json::Value> {
|
|
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 actually contains rather than implementing the whole rule set,
|
|
/// and anything unexpected becomes `%XX` rather than being passed through.
|
|
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()
|
|
}
|