Merge branch 'main' of git.arirex.me:iris/ai-app
# 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
This commit is contained in:
commit
3c0214ece8
94 files changed
+8299
-9454
No files matched your search
+84
-118
@@ -1,28 +1,22 @@
|
||||
//! GGUF models on this machine, and the downloads that produce them.
|
||||
//!
|
||||
//! The registry pattern again (see `session`): one owner, one lock, so what
|
||||
//! is on disk and what this server believes cannot come apart.
|
||||
//!
|
||||
//! Three things shape the design, all of them consequences of a model file
|
||||
//! being gigabytes rather than kilobytes:
|
||||
//! 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, and one that opened the app
|
||||
//! after it finished. State in a per-connection channel would not survive
|
||||
//! the phone locking its screen, which for an hour-long download is the
|
||||
//! normal case rather than an edge one.
|
||||
//! 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 different answers at once -- it finished,
|
||||
//! it never started, or a different run finished while you were away --
|
||||
//! and over an hour that ambiguity is certain to be hit. A device compares
|
||||
//! the run it was watching against the run reported now.
|
||||
//! **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, rather
|
||||
//! than a bar drawn from how long the last download took.
|
||||
//! `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};
|
||||
@@ -42,35 +36,33 @@ use crate::session::transport::{Launch, Transport};
|
||||
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.
|
||||
/// 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.
|
||||
/// `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.
|
||||
/// 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.
|
||||
/// 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,
|
||||
@@ -82,20 +74,17 @@ pub enum DownloadState {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadStatus {
|
||||
pub key: String,
|
||||
/// Distinguishes this run from any earlier one for the same model.
|
||||
/// A device that was watching run 3 can tell that what it is looking
|
||||
/// at now is run 4 rather than assuming its own run ended.
|
||||
/// 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,
|
||||
/// Bytes on disk, including any carried over from a resumed attempt.
|
||||
pub done: u64,
|
||||
/// What `Content-Length` said, or absent when the server did not say.
|
||||
/// Absent means "unknown", never "zero" -- see this module's doc.
|
||||
/// Absent means "unknown", never "zero".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total: Option<u64>,
|
||||
/// Present only when [`DownloadState::Failed`], and it is the reason.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub started: f64,
|
||||
@@ -154,9 +143,8 @@ impl Run {
|
||||
/// 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. Bounded by how many distinct models have been asked for.
|
||||
/// 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,
|
||||
}
|
||||
@@ -173,11 +161,10 @@ impl ModelStore {
|
||||
/// Where a model's file lives, refusing anything that would escape the
|
||||
/// models directory.
|
||||
///
|
||||
/// The repo and file come from a phone, and this server runs as the
|
||||
/// user who started it, so they are treated as hostile: every
|
||||
/// component must be an ordinary name. Rejecting is deliberate rather
|
||||
/// than sanitising, since a silently rewritten path would download the
|
||||
/// right bytes to the wrong place.
|
||||
/// 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('/')) {
|
||||
@@ -193,11 +180,9 @@ impl ModelStore {
|
||||
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, and the directory is small enough
|
||||
/// that walking it costs nothing next to loading a model.
|
||||
/// 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);
|
||||
@@ -213,12 +198,10 @@ impl ModelStore {
|
||||
all
|
||||
}
|
||||
|
||||
/// Starts fetching `file` from `repo`, or returns the run already
|
||||
/// doing so.
|
||||
///
|
||||
/// Idempotent on purpose: a phone that lost its connection and came
|
||||
/// back will press the button again, and that must join the existing
|
||||
/// run rather than start a second one writing the same file.
|
||||
/// 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)?;
|
||||
@@ -253,8 +236,8 @@ impl ModelStore {
|
||||
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.
|
||||
// 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);
|
||||
@@ -277,8 +260,8 @@ impl ModelStore {
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Asks a running download to stop. The partial file stays, so
|
||||
/// starting again resumes rather than refetching.
|
||||
/// 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 {
|
||||
@@ -305,7 +288,6 @@ impl ModelStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The download loop: resume where a partial left off, write, report.
|
||||
fn fetch(&self, run: &Run, target: &Path) -> Result<()> {
|
||||
let partial = partial_of(target);
|
||||
let identity = identity_of(target);
|
||||
@@ -313,9 +295,8 @@ impl ModelStore {
|
||||
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 -- so it is refetched rather than guessed at.
|
||||
// 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());
|
||||
@@ -333,13 +314,11 @@ impl ModelStore {
|
||||
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 rather than 200 with the whole file. 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. Throw the partial away and
|
||||
// ask again from zero.
|
||||
// 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",
|
||||
@@ -351,11 +330,9 @@ impl ModelStore {
|
||||
etag = etag_of(&response);
|
||||
}
|
||||
|
||||
// On a 206, Content-Length is the length of the *range*, not of
|
||||
// the file -- it answers a different question than the one a
|
||||
// progress bar asks, and 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 (`bytes 162000000-234074815/234074816`), which has
|
||||
// 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
|
||||
@@ -380,12 +357,10 @@ impl ModelStore {
|
||||
p.total = total;
|
||||
}
|
||||
|
||||
// `truncate(false)` is the whole resume story: the file is opened
|
||||
// to be seeked into and appended to, and truncating here would
|
||||
// throw away exactly the bytes the Range request just asked the
|
||||
// server not to send again. Stated rather than left to the
|
||||
// default, because the default is what a reader would have to
|
||||
// remember.
|
||||
// `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)
|
||||
@@ -399,9 +374,8 @@ impl ModelStore {
|
||||
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. That is
|
||||
// what makes it safe to keep one across a restart of this server.
|
||||
// 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();
|
||||
}
|
||||
@@ -427,12 +401,10 @@ impl ModelStore {
|
||||
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 download of
|
||||
// this size has too many ways to go subtly wrong to take on
|
||||
// trust, and because a wrong model is the kind of failure that
|
||||
// surfaces as bad output rather than as an error.
|
||||
// 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)?;
|
||||
@@ -448,8 +420,8 @@ impl ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Renamed only once complete, so a file at its real name is always
|
||||
// a whole model -- `list` needs no other way to tell.
|
||||
// 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();
|
||||
@@ -457,9 +429,8 @@ impl ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ever makes.
|
||||
/// 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 =
|
||||
@@ -473,8 +444,8 @@ fn sha256_of(path: &Path) -> Result<String> {
|
||||
}
|
||||
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.
|
||||
// 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()
|
||||
@@ -489,9 +460,8 @@ fn request(url: &str, from: u64) -> Result<(ureq::http::Response<ureq::Body>, bo
|
||||
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.
|
||||
// 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))
|
||||
}
|
||||
@@ -508,8 +478,8 @@ fn etag_of(response: &ureq::http::Response<ureq::Body>) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial
|
||||
/// beside it is a piece of.
|
||||
/// `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");
|
||||
@@ -646,18 +616,17 @@ pub struct RemoteRepo {
|
||||
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.
|
||||
/// 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, so it is also the one whose view of what exists is relevant.
|
||||
/// 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",
|
||||
@@ -685,11 +654,9 @@ pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
|
||||
.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 on a download rather
|
||||
/// than something we would have to compute a second source of truth for.
|
||||
/// 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()?;
|
||||
@@ -738,10 +705,9 @@ fn get_json(url: &str) -> Result<serde_json::Value> {
|
||||
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.
|
||||
/// 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()
|
||||
|
||||
Reference in new issue
Block a user