From ef70b795d1b14fc63d244ee04a84fc5d8b9f519a Mon Sep 17 00:00:00 2001 From: Tim Toole Date: Fri, 7 Aug 2026 14:34:32 -0700 Subject: [PATCH] pull: add Hugging Face org/repo[:quant] downloads and serve --hf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camelid could only pull the ~30 curated catalog rows from the CLI; arbitrary Hugging Face GGUFs were reachable only through the Models page's experimental browse lane. This adds the CLI twin of that lane: camelid pull prism-ml/Ternary-Bonsai-27B-gguf:Q2_0 camelid serve --hf prism-ml/Ternary-Bonsai-27B-gguf:Q2_0 Any pull query containing '/' is a Hugging Face spec (curated ids never contain one); serve/chat --hf download-if-missing then load through the ordinary explicit-model path. Prerequisite for a Hugging Face Local Apps entry, whose "Use this model" snippet needs a one-line spec-addressable command. Support contract unchanged: this lane is experimental — unverified, no parity claim. A download path is not a support claim, and runnability is still decided at load time by the inspect-first typed-blocker flow, fail-closed. See D21. Reuse over reinvention: discovery goes through hf_browse's LFS-aware tree fetch (extracted as list_gguf_files_blocking); the download adopts the web installer's semantics (.part + rename promotion, resume, retries, stall detection, download ceiling). Stricter than the web install path in two places: repo ids are gated by fit_dims::is_safe_hf_component (the /catalog/fit contract, which install skips) and Windows reserved device stems are rejected. Selection fails closed rather than guessing: a multi-GGUF repo requires an explicit :quant; recognized labels must match exactly (:Q4_K never resolves to Q4_K_M) and unrecognized ones match on filename token boundaries (:F16 never picks BF16); mmproj companions and multi-part shards are unselectable; a same-named file with different bytes is an error, never an overwrite; and a stale oversized .part is discarded rather than resumed forever. guess_quant now matches on token boundaries and knows the _L/_XL/Q4_0_x_y variants — as a substring matcher it labeled Q6_K_L as "Q6_K", which made plain tags ambiguous in the standard bartowski layout and could silently substitute a different quantization. --hf is CLI-only (no env alias) and not a clap conflict with --model, so an exported CAMELID_MODEL cannot make it unusable and no inherited variable can start a download on desktop app open; a typed --hf wins at dispatch. serve --hf honors --max-download-bytes and downloads into the same directory the server scans (api::resolve_models_dir), so pulled files always appear in the Models page. Validation: cargo fmt --check, clippy --all-targets -D warnings, and cargo test --all-targets (2041 passed, 0 failed) all green on macOS; live end-to-end pull of unsloth/SmolLM2-135M-Instruct-GGUF:Q2_K exercised first download, size-verified skip, resume from a truncated partial, and stale-partial recovery. Docs updated in the same change: README quick start and pull-catalog sections, docs/CONFIGURATION.md ceiling table, DECISIONS.md D21, DOCS.md index. Co-Authored-By: Claude Opus 5 --- DECISIONS.md | 39 ++ DOCS.md | 2 +- README.md | 10 +- docs/CONFIGURATION.md | 8 + src/api/mod.rs | 3 +- src/catalog.rs | 39 ++ src/hf_browse.rs | 125 +++++- src/hf_pull.rs | 991 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 227 +++++++++- 10 files changed, 1409 insertions(+), 36 deletions(-) create mode 100644 src/hf_pull.rs diff --git a/DECISIONS.md b/DECISIONS.md index f94312c2..549d022d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1433,3 +1433,42 @@ platform-blind (`supported_exact_row_smoke_sub512` for the gemma3 row) because that table is keyed on the row name alone and is reported on fallback hosts too; the lane-aware context claim lives in `/api/capabilities`, which is the support source of truth and states the lane it applies to. + +## D21 — CLI pull of arbitrary Hugging Face GGUFs (`org/repo[:quant]`) (2026-08-06) + +**Decision:** `camelid pull` accepts a Hugging Face `org/repo[:quant]` spec (any +query containing `/`; curated ids never contain one), and `serve --hf` / +`chat --hf` download-if-missing then load through the ordinary explicit-model +path. The lane is the CLI twin of the Models page's "Experimental (Hugging +Face)" group and inherits its wording verbatim: **experimental — unverified, no +parity claim**; a download path is not a support claim; runnability is decided +at load time by the inspect-first typed-blocker flow, fail-closed. This is a +prerequisite for listing Camelid in the Hugging Face Hub's "Use this model" +Local Apps dropdown, whose snippet needs a one-line spec-addressable command. + +**Basis (receipts):** discovery and sizes reuse `hf_browse::list_gguf_files_blocking` +(the browse lane's LFS-aware tree fetch, extracted from `repo_gguf_files`); +downloads adopt the web installer's semantics — `.part` + rename promotion so a +loadable GGUF never exists half-written, resume via `curl -C -`, retries and +stall detection, and the `CAMELID_MAX_DOWNLOAD_BYTES` ceiling enforced before +and during transfer (`src/api/mod.rs` `spawn_catalog_artifact_download`) — while +keeping the curated pull's live size verification against the Hub tree. Unlike +the web install handler, the CLI validates `repo_id` with +`fit_dims::is_safe_hf_component` (the stricter `/catalog/fit` contract) in +addition to gating filenames through `model_default::valid_local_model_filename`. + +**Fail-closed selection:** a multi-GGUF repo requires an explicit `:quant`; tags +match recognized quant labels exactly, otherwise on filename token boundaries +(`:F16` can never silently select a `BF16` file). `mmproj` companions and +multi-part shards are never selectable. Same-filename collisions in the flat +models dir are an error, never an overwrite. Anonymous downloads only — gated +repos are out of scope. `--hf` is deliberately CLI-only (no env alias, and not a +clap conflict with `--model`: an exported `CAMELID_MODEL` must not make the flag +unusable, so a typed `--hf` wins at dispatch), which also means no inherited env +var can start a download on desktop app open. `serve --hf` honors the +`--max-download-bytes` flag; `pull`/`chat --hf` read the env var only. + +**What this does not decide:** no support-contract change of any kind — no +COMPATIBILITY.md row, no `/api/capabilities` change, no sha256 pinning for +arbitrary files (the Hub tree byte count is the only integrity gate, matching +the web lane), and no multi-part or vision-companion download support. diff --git a/DOCS.md b/DOCS.md index cc08720e..add5a9db 100644 --- a/DOCS.md +++ b/DOCS.md @@ -34,7 +34,7 @@ Read these first: - [`SECURITY.md`](SECURITY.md) — security reporting guidance - [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) — acknowledgements and license notices - [`DROVER_RECON.md`](DROVER_RECON.md) — the agent-mode campaign record: recon, gate ledger, amendment log -- [`DECISIONS.md`](DECISIONS.md) — the live decision log (D1–D20, including D20's routing invariants for windowed-attention architectures; `docs/architecture/DECISIONS.md` is the frozen early-phase log) +- [`DECISIONS.md`](DECISIONS.md) — the live decision log (D1–D21, including D21's experimental Hugging Face pull lane (`camelid pull org/repo[:quant]`, `serve --hf`); `docs/architecture/DECISIONS.md` is the frozen early-phase log) ## QA and acceptance docs diff --git a/README.md b/README.md index 2be0180d..86bc44bf 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,14 @@ Camelid opens `http://127.0.0.1:8181`. Use `camelid chat` for the terminal UI, o Run `camelid pull` without an argument to list the curated model catalog. +Public Hugging Face repos that ship single-file, top-level GGUFs can also be pulled directly by `org/repo[:quant]` spec (multi-part shards, nested files, and gated repos are not supported), or downloaded and served in one step with `--hf`: + +```bash +camelid serve --hf prism-ml/Ternary-Bonsai-27B-gguf:Q2_0 +``` + +Files pulled this way are **experimental — unverified, no parity claim**: a download path is not a support claim, and an unsupported file still fails closed at load, exactly as in the Models page's experimental Hugging Face group. When a repo ships several quantizations, Camelid lists them and asks for an explicit `:quant` instead of guessing; add `--dry-run` to `camelid pull` to preview the resolved file without downloading. + > [!WARNING] > A non-loopback listener requires authentication. Prefer an API key file: > @@ -99,7 +107,7 @@ Good starting points: ### Full `camelid pull` catalog -Run `camelid pull ` to download a model into `./models`. Pull IDs resolve by unique substring; if a fragment matches several rows, Camelid lists the matches instead of guessing. +Run `camelid pull ` to download a model into `./models`. Pull IDs resolve by unique substring; if a fragment matches several rows, Camelid lists the matches instead of guessing. A query containing `/` is treated as a Hugging Face `org/repo[:quant]` spec instead and downloads outside this catalog — experimental lane, unverified, no parity claim. | Model | Quant | Arch | Size | Pull ID | GGUF file | |---|---|---|---:|---|---| diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 34cc51fd..c3618eaa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -89,6 +89,14 @@ Resource ceilings are resolved once at startup. Their CLI names and environment | `--max-generation-tokens` | 8,192 | `CAMELID_MAX_GENERATION_TOKENS` | | `--max-download-bytes` | 64 GiB | `CAMELID_MAX_DOWNLOAD_BYTES` | +The download ceiling also applies to the Hugging Face spec lanes, which download arbitrary +GGUFs into the models directory — experimental lane, unverified, no parity claim: `serve --hf` +honors the `--max-download-bytes` flag (and its env alias), while `camelid pull +org/repo[:quant]` and `chat --hf` have no such flag and read `CAMELID_MAX_DOWNLOAD_BYTES` +only. `--hf` is deliberately CLI-only (no env alias), so no inherited environment variable can +ever start a multi-gigabyte download, and the double-click desktop launch never downloads. +Downloads are anonymous; gated or private repos are not supported. + `GET /metrics` exposes bounded-name Prometheus counters and gauges for HTTP/generation latency, prompt/decode tokens, prompt and weight cache outcomes, engine queue/slot progress, process RSS, and CUDA VRAM. It contains no model-path, prompt, API-key, or per-user labels. diff --git a/src/api/mod.rs b/src/api/mod.rs index 7a2410db..387865d9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -38,6 +38,7 @@ mod server; mod workspace; pub use server::ServeOptions; +pub(crate) use server::DEFAULT_MAX_DOWNLOAD_BYTES; use crate::{ embedding::{cosine_similarity, EncoderConfig, NomicBertRuntime}, @@ -2212,7 +2213,7 @@ fn default_models_dir() -> PathBuf { /// not require the directory to exist yet (a fresh install has no models/ until /// the first download) and never produces the Windows `\\?\` verbatim prefix in /// user-facing strings. -fn resolve_models_dir(configured: Option) -> PathBuf { +pub fn resolve_models_dir(configured: Option) -> PathBuf { let dir = configured.unwrap_or_else(default_models_dir); if dir.is_absolute() { dir diff --git a/src/catalog.rs b/src/catalog.rs index 0cba12cf..f56174f4 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -14,6 +14,20 @@ use crate::api::{curated_catalog, CatalogItem}; /// otherwise resolves `query` to exactly one row and downloads it into /// `models_dir`. pub fn run_pull(query: Option<&str>, models_dir: &Path) -> anyhow::Result<()> { + run_pull_opts(query, models_dir, false) +} + +/// [`run_pull`] with CLI-only options: `dry_run` resolves and prints what would +/// be downloaded without moving bytes. +pub fn run_pull_opts(query: Option<&str>, models_dir: &Path, dry_run: bool) -> anyhow::Result<()> { + // An `org/repo[:quant]` spec pulls straight from Hugging Face (experimental + // lane, unverified); curated catalog ids never contain a '/'. + if let Some(query) = query { + if crate::hf_pull::is_hf_spec(query) { + return crate::hf_pull::run_hf_pull(query, models_dir, dry_run).map(|_| ()); + } + } + let entries = curated_catalog(); let Some(query) = query else { @@ -23,6 +37,18 @@ pub fn run_pull(query: Option<&str>, models_dir: &Path) -> anyhow::Result<()> { }; let item = resolve(&entries, query)?; + if dry_run { + eprintln!("Would download:"); + eprintln!(" model: {} ({})", item.name, item.quant); + eprintln!(" repo: {}", item.repo_id); + eprintln!( + " file: {} ({:.1} GB)", + item.filename, + item.size_bytes as f64 / 1e9 + ); + eprintln!(" dest: {}", models_dir.join(item.filename).display()); + return Ok(()); + } let dest = download(&item, models_dir)?; eprintln!("\n✓ {} is ready at {}", item.name, dest.display()); @@ -556,4 +582,17 @@ mod tests { assert_eq!(item.filename, "Qwen3-4B-Q4_K_M.gguf"); assert_eq!(item.quant, "Q4_K_M"); } + + #[test] + fn curated_dry_run_resolves_offline_and_writes_nothing() { + // --dry-run must stop after resolution: no models dir, no bytes, no + // network (the remote size check lives inside download()). + let dir = std::env::temp_dir().join(format!( + "camelid-catalog-dry-run-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + run_pull_opts(Some("qwen3_4b_q4_k_m"), &dir, true).expect("dry-run resolves"); + assert!(!dir.exists(), "dry-run must not create the models dir"); + } } diff --git a/src/hf_browse.rs b/src/hf_browse.rs index c596d59b..a097d991 100644 --- a/src/hf_browse.rs +++ b/src/hf_browse.rs @@ -257,16 +257,28 @@ fn fetch_repo_files_parallel(repos: &[RepoMeta]) -> Vec { collected.into_iter().flat_map(|(_, files)| files).collect() } +/// Browse-search wrapper over [`list_gguf_files_blocking`]: patches in the +/// downloads/likes the search response knows but the tree endpoint doesn't. +fn repo_gguf_files(repo: &RepoMeta) -> anyhow::Result> { + let mut files = list_gguf_files_blocking(&repo.id)?; + for file in &mut files { + file.downloads = repo.downloads; + file.likes = repo.likes; + } + Ok(files) +} + /// Enumerate the top-level `*.gguf` files in a repo with LFS-aware sizes, mirroring /// the `remote_size()` logic in `catalog.rs`. Only top-level files are returned: /// the downloader writes to `models/` and the local scan globs /// `models/*.gguf`, so a nested path would download but never surface as a local /// model. Sharded/subdir GGUFs are therefore skipped at browse time. -fn repo_gguf_files(repo: &RepoMeta) -> anyhow::Result> { - let url = format!( - "https://huggingface.co/api/models/{}/tree/main?recursive=1", - repo.id - ); +/// +/// Shared by browse search results and the CLI `pull org/repo[:quant]` resolver +/// (`crate::hf_pull`); `downloads`/`likes` are zeroed here because they are +/// repo-search metadata the tree endpoint does not report. +pub(crate) fn list_gguf_files_blocking(repo_id: &str) -> anyhow::Result> { + let url = format!("https://huggingface.co/api/models/{repo_id}/tree/main?recursive=1"); let (body, _) = curl_get_with_headers(&url)?; let tree: serde_json::Value = serde_json::from_slice(&body) .map_err(|err| anyhow::anyhow!("could not parse hugging face tree response: {err}"))?; @@ -292,12 +304,12 @@ fn repo_gguf_files(repo: &RepoMeta) -> anyhow::Result> { .unwrap_or(0); out.push(HfGgufFile { - repo_id: repo.id.clone(), + repo_id: repo_id.to_string(), filename: path.to_string(), size_bytes: size, - downloads: repo.downloads, - likes: repo.likes, - architecture: guess_architecture(path, &repo.id), + downloads: 0, + likes: 0, + architecture: guess_architecture(path, repo_id), quant: guess_quant(path).unwrap_or_default(), }); } @@ -381,7 +393,9 @@ fn extract_query_param(url: &str, key: &str) -> Option { } /// Percent-encode a query component (RFC 3986 unreserved set kept verbatim). -fn urlencode(s: &str) -> String { +/// Also safe for a path segment (it never emits a literal `/`), which is how +/// `crate::hf_pull` uses it for resolve-URL filenames. +pub(crate) fn urlencode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { @@ -413,19 +427,57 @@ fn urldecode(s: &str) -> String { String::from_utf8_lossy(&out).into_owned() } -/// Best-effort quant guess from a filename (advisory only). Longest tokens first so -/// `Q4_K_M` isn't shadowed by `Q4_K`. -fn guess_quant(filename: &str) -> Option { - let upper = filename.to_uppercase(); - const PATTERNS: &[&str] = &[ - "IQ2_XXS", "IQ3_XXS", "IQ2_XS", "IQ3_XS", "IQ4_XS", "IQ4_NL", "IQ1_S", "IQ1_M", "IQ2_S", - "IQ2_M", "IQ3_S", "IQ3_M", "Q2_K_S", "Q3_K_S", "Q3_K_M", "Q3_K_L", "Q4_K_S", "Q4_K_M", - "Q5_K_S", "Q5_K_M", "Q6_K", "Q8_K", "Q2_K", "Q3_K", "Q4_K", "Q5_K", "Q4_0", "Q4_1", "Q5_0", - "Q5_1", "Q8_0", "BF16", "F16", "F32", - ]; - PATTERNS +/// The quant labels [`guess_quant`] recognizes, longest first so `Q4_K_M` is +/// tried before `Q4_K`. Includes the `_L`/`_XL` and `Q4_0_x_y` superstring +/// variants: without them a `Q6_K_L` file would be labeled `Q6_K`, and the +/// `pull org/repo[:quant]` exact-tag stage would silently select the wrong +/// quantization. +const QUANT_PATTERNS: &[&str] = &[ + "Q4_0_4_4", "Q4_0_4_8", "Q4_0_8_8", "Q2_K_XL", "Q3_K_XL", "Q4_K_XL", "Q5_K_XL", "Q6_K_XL", + "Q8_K_XL", "IQ2_XXS", "IQ3_XXS", "IQ2_XS", "IQ3_XS", "IQ4_XS", "IQ4_NL", "IQ1_S", "IQ1_M", + "IQ2_S", "IQ2_M", "IQ3_S", "IQ3_M", "Q2_K_S", "Q2_K_L", "Q3_K_S", "Q3_K_M", "Q3_K_L", "Q4_K_S", + "Q4_K_M", "Q4_K_L", "Q5_K_S", "Q5_K_M", "Q5_K_L", "Q6_K_L", "MXFP4", "TQ1_0", "TQ2_0", "Q6_K", + "Q8_K", "Q2_K", "Q3_K", "Q4_K", "Q5_K", "Q4_0", "Q4_1", "Q5_0", "Q5_1", "Q8_0", "BF16", "F16", + "F32", +]; + +/// True when `label` (uppercase) is a quant label [`guess_quant`] can assign. +pub(crate) fn known_quant_label(label: &str) -> bool { + QUANT_PATTERNS.contains(&label) +} + +/// True when uppercase-ASCII `needle` occurs in `hay` (compared +/// case-insensitively) delimited by non-alphanumerics or the string edges. +/// Byte-based on the uppercased haystack so multi-byte characters can never +/// cause a mid-character slice. +pub(crate) fn token_bounded_contains(hay: &str, needle: &str) -> bool { + let hay_string = hay.to_uppercase(); + let hay = hay_string.as_bytes(); + let needle = needle.as_bytes(); + if needle.is_empty() || needle.len() > hay.len() { + return false; + } + for start in 0..=hay.len() - needle.len() { + if &hay[start..start + needle.len()] != needle { + continue; + } + let end = start + needle.len(); + let left_ok = start == 0 || !hay[start - 1].is_ascii_alphanumeric(); + let right_ok = end == hay.len() || !hay[end].is_ascii_alphanumeric(); + if left_ok && right_ok { + return true; + } + } + false +} + +/// Best-effort quant guess from a filename (advisory only). Labels match on +/// token boundaries — `model-Q6_K_L.gguf` is `Q6_K_L`, never `Q6_K`, and a +/// name with no delimited label gets no guess rather than a wrong one. +pub(crate) fn guess_quant(filename: &str) -> Option { + QUANT_PATTERNS .iter() - .find(|p| upper.contains(**p)) + .find(|p| token_bounded_contains(filename, p)) .map(|p| (*p).to_string()) } @@ -475,6 +527,35 @@ mod tests { assert_eq!(guess_quant("model.gguf"), None); } + #[test] + fn quant_labels_match_on_token_boundaries() { + // Superstring variants get their own label, never the shorter prefix — + // the pull tag stage relies on this to avoid silent wrong-quant picks. + assert_eq!(guess_quant("model-Q6_K_L.gguf").as_deref(), Some("Q6_K_L")); + assert_eq!( + guess_quant("model-Q4_0_8_8.gguf").as_deref(), + Some("Q4_0_8_8") + ); + assert_eq!( + guess_quant("model-UD-Q4_K_XL.gguf").as_deref(), + Some("Q4_K_XL") + ); + // BF16 is not F16. + assert_eq!(guess_quant("model-BF16.gguf").as_deref(), Some("BF16")); + // An undelimited token is no longer guessed (advisory honesty). + assert_eq!(guess_quant("modelq4_k_m0.gguf"), None); + } + + #[test] + fn token_bounded_contains_is_boundary_and_case_aware() { + assert!(token_bounded_contains("Model-Q2_0.gguf", "Q2_0")); + assert!(!token_bounded_contains("Model-PQ2_0.gguf", "Q2_0")); + assert!(!token_bounded_contains("Model-BF16.gguf", "F16")); + assert!(token_bounded_contains("model-q2_g64.gguf", "Q2_G64")); + assert!(token_bounded_contains("Model-Q4_K_M.gguf", "Q4_K")); + assert!(!token_bounded_contains("anything", "")); + } + #[test] fn guesses_architecture_advisory() { assert_eq!( diff --git a/src/hf_pull.rs b/src/hf_pull.rs new file mode 100644 index 00000000..973bbdcd --- /dev/null +++ b/src/hf_pull.rs @@ -0,0 +1,991 @@ +//! `camelid pull org/repo[:quant]` — fetch an arbitrary Hugging Face GGUF from +//! the terminal, the CLI twin of the Models page's "Experimental (Hugging Face)" +//! browse lane. +//! +//! Policy: a download path is not a support claim. Everything fetched here is +//! experimental-lane — unverified, no parity claim — and whether the file loads +//! is decided at load time by the inspect-first typed-blocker flow (fail-closed), +//! exactly as for a file downloaded from the web UI. This module therefore +//! discovers, selects, and downloads; it never asserts runnability. +//! +//! Selection is deliberately fail-closed too: a repo with several GGUFs requires +//! an explicit `:quant` tag rather than guessing on the user's behalf, and a tag +//! only matches on token boundaries so `:F16` cannot silently pick a `BF16` +//! file. Vision projector (`mmproj`) companions and multi-part shards are never +//! selectable — the engine loads single-file models from `models/*.gguf`. +//! +//! Network and disk mechanics follow the audited paths: the tree listing reuses +//! `hf_browse` (LFS-aware sizes, curl subprocess, offline degrades to a typed +//! error) and the download mirrors the web installer's semantics — bare-filename +//! destinations gated by `valid_local_model_filename`, a `.part` file promoted +//! by rename only after the size gate passes, resume via `curl -C -`, and the +//! download ceiling (`CAMELID_MAX_DOWNLOAD_BYTES`; `serve --hf` honors the +//! `--max-download-bytes` flag) enforced both before and during the transfer. +//! Downloads are anonymous: gated/private repos are not supported. + +use std::path::{Path, PathBuf}; + +use crate::hf_browse::HfGgufFile; + +/// A parsed `org/repo[:quant]` spec. `quant` is stored uppercased; matching is +/// case-insensitive throughout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HfModelSpec { + pub repo_id: String, + pub quant: Option, +} + +/// `pull` treats any query containing `/` as a Hugging Face spec: curated +/// catalog ids never contain one. +pub fn is_hf_spec(query: &str) -> bool { + query.contains('/') +} + +impl HfModelSpec { + /// Parse `org/repo[:quant]`, tolerating pasted `hf.co/…` and + /// `huggingface.co/…` URL prefixes and a trailing slash. + pub fn parse(raw: &str) -> anyhow::Result { + let mut rest = raw.trim(); + for prefix in [ + "https://huggingface.co/", + "http://huggingface.co/", + "https://hf.co/", + "http://hf.co/", + "huggingface.co/", + "hf.co/", + ] { + // `get` (not a direct slice): a multi-byte character straddling the + // prefix byte length must fall through to the parse error below, not + // panic on a non-char-boundary index. + if let Some(head) = rest.get(..prefix.len()) { + if head.eq_ignore_ascii_case(prefix) { + rest = &rest[prefix.len()..]; + break; + } + } + } + + let (repo_part, quant) = match rest.rsplit_once(':') { + Some((repo, tag)) => (repo, Some(tag)), + None => (rest, None), + }; + let repo_id = repo_part.trim_end_matches('/'); + + let parts: Vec<&str> = repo_id.split('/').collect(); + let repo_ok = parts.len() == 2 + && parts.iter().all(|part| !part.is_empty()) + && crate::fit_dims::is_safe_hf_component(repo_id); + if !repo_ok { + anyhow::bail!( + "\"{raw}\" is not a Hugging Face model spec — expected org/repo or org/repo:QUANT \ + (e.g. prism-ml/Ternary-Bonsai-27B-gguf:Q2_0)" + ); + } + + let quant = match quant { + None => None, + Some(tag) => { + let tag = tag.trim(); + let tag_ok = !tag.is_empty() + && tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')); + if !tag_ok { + anyhow::bail!( + "\"{raw}\" has an invalid quant tag — write org/repo:QUANT \ + (e.g. :Q8_0) or drop the colon" + ); + } + Some(tag.to_uppercase()) + } + }; + + Ok(Self { + repo_id: repo_id.to_string(), + quant, + }) + } +} + +/// Resolve and download a spec into `models_dir`, printing the follow-up +/// `camelid serve` command. `dry_run` stops after resolution and prints what +/// would be downloaded. Returns the destination path. +pub fn run_hf_pull(raw_spec: &str, models_dir: &Path, dry_run: bool) -> anyhow::Result { + pull_spec(raw_spec, models_dir, dry_run, true, None) +} + +/// `serve --hf` / `chat --hf` entry point: download-if-missing, no +/// "serve it" hint (the caller is already starting the engine). +/// `max_download_bytes` overrides the `CAMELID_MAX_DOWNLOAD_BYTES` env default +/// — `serve` passes its `--max-download-bytes` value so the flag governs this +/// lane too; callers without such a flag (chat) pass `None`. +pub fn ensure_hf_model( + raw_spec: &str, + models_dir: &Path, + max_download_bytes: Option, +) -> anyhow::Result { + pull_spec(raw_spec, models_dir, false, false, max_download_bytes) +} + +fn pull_spec( + raw_spec: &str, + models_dir: &Path, + dry_run: bool, + print_next_step: bool, + ceiling_override: Option, +) -> anyhow::Result { + let spec = HfModelSpec::parse(raw_spec)?; + + eprintln!("Listing GGUF files in {} …", spec.repo_id); + let files = crate::hf_browse::list_gguf_files_blocking(&spec.repo_id).map_err(|err| { + anyhow::anyhow!( + "could not list {}: {err} — check the repo id; gated or private repos are not \ + supported (camelid downloads anonymously)", + spec.repo_id + ) + })?; + if files.is_empty() { + anyhow::bail!( + "{} has no top-level .gguf files — camelid loads single-file GGUFs from the top \ + level of a repo", + spec.repo_id + ); + } + + let chosen = select_file(&spec, &files)?; + + // Disclosure wording is the web UI's, verbatim: this lane never claims more + // than "the bytes arrived". + eprintln!("\nExperimental — unverified, no parity claim."); + eprintln!( + "A download path is not a support claim; whether this file loads is decided at load \ + time, fail-closed." + ); + + let dest = models_dir.join(&chosen.filename); + if dry_run { + eprintln!("\nWould download:"); + eprintln!(" repo: {}", chosen.repo_id); + eprintln!( + " file: {} ({})", + chosen.filename, + human_gb(chosen.size_bytes) + ); + eprintln!(" url: {}", resolve_url(&chosen)); + eprintln!(" dest: {}", dest.display()); + return Ok(dest); + } + + let ceiling = ceiling_override.unwrap_or_else(max_download_bytes); + let dest = download(&chosen, models_dir, ceiling)?; + + eprintln!("\n✓ {} downloaded to {}", chosen.filename, dest.display()); + if files.iter().any(|file| is_mmproj(&file.filename)) { + eprintln!( + "note: {} also ships vision projector (mmproj) files; `camelid pull` fetches the \ + model file only.", + spec.repo_id + ); + } + if print_next_step { + // No runnability claim: loading is decided fail-closed by the engine. + eprintln!( + "\nServe it (loads only if the file's architecture is implemented; fails closed \ + otherwise):\n camelid serve --model {}", + dest.display() + ); + } + Ok(dest) +} + +/// Pick exactly one downloadable file for `spec`, or explain why the CLI will +/// not guess. Companions (mmproj) and multi-part shards are excluded up front. +fn select_file(spec: &HfModelSpec, files: &[HfGgufFile]) -> anyhow::Result { + let candidates: Vec<&HfGgufFile> = files + .iter() + .filter(|file| !is_mmproj(&file.filename) && !is_multipart_shard(&file.filename)) + .collect(); + + if candidates.is_empty() { + if files.iter().any(|file| is_multipart_shard(&file.filename)) { + anyhow::bail!( + "{} only has multi-part GGUFs, which camelid pull does not support", + spec.repo_id + ); + } + anyhow::bail!( + "{} has no standalone GGUF model files (only mmproj projector files)", + spec.repo_id + ); + } + + let Some(tag) = &spec.quant else { + if let [only] = candidates.as_slice() { + return Ok((*only).clone()); + } + print_file_list(spec, &candidates); + anyhow::bail!( + "{} has {} GGUF files — add : to pick one (e.g. camelid pull {}:{})", + spec.repo_id, + candidates.len(), + spec.repo_id, + listing_tags(&candidates)[0].0 + ); + }; + + // Stage 1: a tag that IS a recognized quant label matches that label exactly + // (so `:Q4_K` picks Q4_K, never Q4_K_M — labels are boundary-matched with + // the superstring variants ranked first, so a Q6_K_L file is labeled + // Q6_K_L, never Q6_K). + let exact: Vec<&HfGgufFile> = candidates + .iter() + .copied() + .filter(|file| file.quant == *tag) + .collect(); + if let [only] = exact.as_slice() { + return Ok((*only).clone()); + } + // A recognized label that no file carries must NOT fall through to the + // substring stage: `:Q4_K` in a repo shipping only Q4_K_M would otherwise + // silently download a different quantization than the user named. + if exact.is_empty() && crate::hf_browse::known_quant_label(tag) { + print_file_list(spec, &candidates); + anyhow::bail!( + "no file in {} has quant {tag} — pick a tag from the list above", + spec.repo_id + ); + } + + // Stage 2: token-boundary substring against the filename, so unrecognized + // labels (PQ2_0, Q2_g64, …) are still addressable, but `:F16` cannot match + // a `BF16` file. + let matched: Vec<&HfGgufFile> = candidates + .iter() + .copied() + .filter(|file| tag_matches(&file.filename, tag)) + .collect(); + match matched.as_slice() { + [] => { + print_file_list(spec, &candidates); + anyhow::bail!( + "no file in {} matches :{tag} — pick a tag from the list above", + spec.repo_id + ); + } + [only] => Ok((*only).clone()), + several => { + print_file_list(spec, several); + anyhow::bail!( + ":{tag} matches several files in {} — be more specific", + spec.repo_id + ); + } + } +} + +/// True when `tag` (uppercase ASCII) occurs in the filename delimited by +/// non-alphanumerics (or the string ends), compared case-insensitively. Shares +/// the boundary matcher with `guess_quant` so tag selection and labeling can +/// never disagree about what counts as a token. +fn tag_matches(filename: &str, tag: &str) -> bool { + crate::hf_browse::token_bounded_contains(filename, tag) +} + +/// Vision projector companions: never a standalone model. +fn is_mmproj(filename: &str) -> bool { + filename.to_ascii_lowercase().contains("mmproj") +} + +/// `…-00001-of-00003.gguf`-style shard names (any digit widths). +fn is_multipart_shard(filename: &str) -> bool { + let lower = filename.to_ascii_lowercase(); + let Some(stem) = lower.strip_suffix(".gguf") else { + return false; + }; + let mut tail = stem.rsplit('-'); + let (Some(total), Some(of), Some(index)) = (tail.next(), tail.next(), tail.next()) else { + return false; + }; + of == "of" + && !total.is_empty() + && total.bytes().all(|b| b.is_ascii_digit()) + && !index.is_empty() + && index.bytes().all(|b| b.is_ascii_digit()) +} + +/// The `:tag` a user could type to select `file` — its recognized quant label +/// when there is one, else the last `-`-separated token of the stem (which the +/// boundary matcher will find case-insensitively), else the whole filename. +fn suggested_tag(file: &HfGgufFile) -> String { + if !file.quant.is_empty() { + return file.quant.clone(); + } + let stem = file_stem(&file.filename); + stem.rsplit('-') + .next() + .filter(|token| !token.is_empty()) + .unwrap_or(stem) + .to_uppercase() +} + +fn file_stem(filename: &str) -> &str { + filename + .strip_suffix(".gguf") + .or_else(|| filename.strip_suffix(".GGUF")) + .unwrap_or(filename) +} + +/// The `(tag, file)` pairs the listing prints. Every printed tag must actually +/// select its file: when two files would get the same suggested tag (two files +/// sharing a quant label), the colliding ones fall back to their full stem, +/// which the boundary matcher resolves uniquely. +fn listing_tags<'a>(files: &[&'a HfGgufFile]) -> Vec<(String, &'a HfGgufFile)> { + let first_pass: Vec = files.iter().map(|file| suggested_tag(file)).collect(); + files + .iter() + .zip(&first_pass) + .map(|(file, tag)| { + let collides = first_pass.iter().filter(|other| *other == tag).count() > 1; + if collides { + (file_stem(&file.filename).to_uppercase(), *file) + } else { + (tag.clone(), *file) + } + }) + .collect() +} + +fn print_file_list(spec: &HfModelSpec, files: &[&HfGgufFile]) { + eprintln!("GGUF files in {}:", spec.repo_id); + for (tag, file) in listing_tags(files) { + eprintln!( + " {:>10} :{:<10} {}", + human_gb(file.size_bytes), + tag, + file.filename + ); + } +} + +fn human_gb(bytes: u64) -> String { + if bytes == 0 { + return "unknown size".to_string(); + } + format!("{:.1} GB", bytes as f64 / 1e9) +} + +fn resolve_url(file: &HfGgufFile) -> String { + // repo_id is gated by `is_safe_hf_component` at parse time; the filename is + // percent-encoded so spaces and reserved characters cannot mangle the URL. + format!( + "https://huggingface.co/{}/resolve/main/{}", + file.repo_id, + crate::hf_browse::urlencode(&file.filename) + ) +} + +/// Download ceiling from the `CAMELID_MAX_DOWNLOAD_BYTES` env var, defaulting +/// to the serve-side installer's 64 GiB constant. +fn max_download_bytes() -> u64 { + parse_max_download_bytes(std::env::var("CAMELID_MAX_DOWNLOAD_BYTES").ok().as_deref()) +} + +/// Pure parse half of [`max_download_bytes`]. Unparseable or ZERO values fall +/// back to the default: a 0 ceiling would block every pull. (Deliberate +/// difference from `serve`, which treats an explicit 0 as a startup error via +/// `ServeOptions` validation rather than silently substituting the default.) +fn parse_max_download_bytes(raw: Option<&str>) -> u64 { + raw.and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(crate::api::DEFAULT_MAX_DOWNLOAD_BYTES) +} + +/// What to do about an existing file at the destination path before any bytes +/// move. Pure so the never-overwrite trichotomy is unit-testable. +#[derive(Debug, PartialEq, Eq)] +enum DestState { + /// No usable file at dest — proceed to download. + Missing, + /// A zero-byte leftover — safe to replace (nothing of the user's to lose). + ReplaceEmpty, + /// Byte count matches the Hub — keep it, skip the download. + Complete, + /// Dest exists but the Hub reports no size — keep the local copy. + KeepUnknownSize, + /// Same filename, different bytes (possibly another repo's file) — error, + /// never overwrite. + Conflict { have: u64 }, +} + +fn classify_dest(dest_len: Option, expected: u64) -> DestState { + match dest_len { + None => DestState::Missing, + Some(0) => DestState::ReplaceEmpty, + Some(_) if expected == 0 => DestState::KeepUnknownSize, + Some(have) if have == expected => DestState::Complete, + Some(have) => DestState::Conflict { have }, + } +} + +/// Windows reserved device names (CON, NUL, COM1…) — writing them as filenames +/// misbehaves on Windows, and upstream repo filenames are attacker-adjacent. +/// `valid_local_model_filename` does not cover these (pre-existing gap in the +/// web lane too), so this lane rejects them itself. +fn windows_reserved_stem(filename: &str) -> bool { + let stem = file_stem(filename); + let base = stem.split('.').next().unwrap_or(stem); + let upper = base.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && upper.as_bytes()[3].is_ascii_digit()) +} + +/// Download `file` into `models_dir` with the web installer's semantics: write +/// to `.part`, resume with `curl -C -`, verify the byte count against +/// the tree listing, and only then rename into place — a loadable GGUF never +/// exists half-written. An existing complete copy is kept; an existing +/// same-named file with DIFFERENT bytes is an error, never overwritten (the +/// flat models dir means two repos can ship the same filename). A stale `.part` +/// larger than the Hub's current size is deleted up front: resume can never +/// shrink a file, so keeping it would loop "re-run to resume" forever. +fn download(file: &HfGgufFile, models_dir: &Path, max_bytes: u64) -> anyhow::Result { + if !crate::model_default::valid_local_model_filename(&file.filename) + || windows_reserved_stem(&file.filename) + { + anyhow::bail!( + "refusing \"{}\" — upstream filenames must be bare *.gguf names", + file.filename + ); + } + let expected = file.size_bytes; // LFS-aware from the tree listing; 0 = unknown + if expected > max_bytes { + anyhow::bail!( + "{} is {} which exceeds the {} download ceiling — raise it with \ + serve --max-download-bytes or CAMELID_MAX_DOWNLOAD_BYTES", + file.filename, + human_gb(expected), + human_gb(max_bytes) + ); + } + + std::fs::create_dir_all(models_dir)?; + let dest = models_dir.join(&file.filename); + let part = models_dir.join(format!("{}.part", file.filename)); + + match classify_dest(std::fs::metadata(&dest).ok().map(|m| m.len()), expected) { + DestState::Missing => {} + DestState::ReplaceEmpty => { + let _ = std::fs::remove_file(&dest); + } + DestState::Complete => { + eprintln!( + "{} already downloaded at {} ({}, size-verified)", + file.filename, + dest.display(), + human_gb(expected) + ); + return Ok(dest); + } + DestState::KeepUnknownSize => { + eprintln!( + "{} already present at {} (upstream size unknown; keeping the local copy)", + file.filename, + dest.display() + ); + return Ok(dest); + } + DestState::Conflict { have } => { + anyhow::bail!( + "{} already exists at {} with {have} bytes but the Hub reports {expected} — \ + remove the local file or pull into a different --models-dir", + file.filename, + dest.display() + ); + } + } + + // A .part larger than the Hub's current size is unrecoverable by resume + // (upstream re-published smaller, or the partial is corrupt): start clean + // instead of failing "re-run to resume" forever. + if expected != 0 { + if let Ok(meta) = std::fs::metadata(&part) { + if meta.len() > expected { + eprintln!( + "Discarding stale partial {} ({} bytes; the Hub file is {expected}) — \ + restarting the download fresh", + part.display(), + meta.len() + ); + let _ = std::fs::remove_file(&part); + } + } + } + + eprintln!( + "Downloading {} ({}) from {}", + file.filename, + human_gb(expected), + file.repo_id + ); + + // Mirrors the web installer's curl line (resume, retries, stall detection, + // in-flight ceiling) but keeps curl's own progress meter on stderr. + let status = std::process::Command::new("curl") + .args([ + "-f", + "-L", + "-C", + "-", + "--connect-timeout", + "30", + "--retry", + "10", + "--retry-delay", + "2", + "--retry-all-errors", + "--speed-limit", + "1024", + "--speed-time", + "30", + "--max-filesize", + &max_bytes.to_string(), + "-o", + ]) + .arg(&part) + .arg(resolve_url(file)) + .status() + .map_err(|err| anyhow::anyhow!("could not run curl (is it installed?): {err}"))?; + + let have_after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + + // An oversized partial can never be repaired by resume; delete it so the + // next run starts clean instead of failing identically forever. + let discard_oversized_part = |have: u64| { + if expected != 0 && have > expected { + let _ = std::fs::remove_file(&part); + true + } else { + false + } + }; + + if !status.success() { + // curl exits non-zero (HTTP 416) when asked to resume an already-complete + // .part; every byte being present is success, not failure. + if expected == 0 || have_after != expected { + if discard_oversized_part(have_after) { + anyhow::bail!( + "download failed (curl exited with {status}); the oversized partial at {} \ + was discarded — re-run to retry", + part.display() + ); + } + anyhow::bail!( + "download failed (curl exited with {status}); re-run to resume {}", + part.display() + ); + } + } + if expected != 0 && have_after != expected { + if discard_oversized_part(have_after) { + anyhow::bail!( + "download produced {have_after} bytes but the Hub reports {expected}; the stale \ + partial at {} was discarded — re-run to retry", + part.display() + ); + } + anyhow::bail!( + "download incomplete: {} is {have_after} bytes, expected {expected} — re-run to \ + resume", + part.display() + ); + } + if have_after == 0 { + anyhow::bail!( + "download produced no bytes for {} — re-run to retry", + file.filename + ); + } + + std::fs::rename(&part, &dest)?; + Ok(dest) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(name: &str, size: u64) -> HfGgufFile { + HfGgufFile { + repo_id: "org/repo".to_string(), + filename: name.to_string(), + size_bytes: size, + downloads: 0, + likes: 0, + architecture: String::new(), + quant: crate::hf_browse::guess_quant(name).unwrap_or_default(), + } + } + + /// The real prism-ml/Ternary-Bonsai-27B-gguf layout (2026-08): nonstandard + /// quant labels, a dspark variant pair, and two mmproj companions. + fn ternary_bonsai() -> Vec { + vec![ + file("Ternary-Bonsai-27B-F16.gguf", 53_808_280_640), + file("Ternary-Bonsai-27B-PQ2_0.gguf", 7_165_121_600), + file("Ternary-Bonsai-27B-Q2_0.gguf", 7_165_121_600), + file("Ternary-Bonsai-27B-Q2_g64.gguf", 7_585_330_240), + file("Ternary-Bonsai-27B-dspark-Q4_1.gguf", 1_946_393_568), + file("Ternary-Bonsai-27B-dspark-bf16.gguf", 7_291_885_792), + file("Ternary-Bonsai-27B-mmproj-BF16.gguf", 931_145_760), + file("Ternary-Bonsai-27B-mmproj-Q8_0.gguf", 629_246_880), + ] + } + + fn spec(raw: &str) -> HfModelSpec { + HfModelSpec::parse(raw).expect("valid spec") + } + + #[test] + fn parses_repo_and_quant_specs() { + assert_eq!( + spec("prism-ml/Ternary-Bonsai-27B-gguf"), + HfModelSpec { + repo_id: "prism-ml/Ternary-Bonsai-27B-gguf".to_string(), + quant: None, + } + ); + assert_eq!( + spec("org/repo:q4_k_m").quant.as_deref(), + Some("Q4_K_M"), + "quant tags are canonicalized to uppercase" + ); + assert_eq!(spec("hf.co/org/repo").repo_id, "org/repo"); + assert_eq!(spec("https://huggingface.co/org/repo/").repo_id, "org/repo"); + assert_eq!(spec("HF.co/org/repo:Q2_0").quant.as_deref(), Some("Q2_0")); + } + + #[test] + fn rejects_malformed_specs() { + for bad in [ + "org", + "org/", + "/repo", + "org/repo/extra", + "org/../repo", + "org/repo:", + "org/repo: ", + "org/re po", + "org/repo:Q4?", + "org/repo:Q4/K", + ] { + assert!( + HfModelSpec::parse(bad).is_err(), + "expected {bad:?} to be rejected" + ); + } + } + + #[test] + fn is_hf_spec_only_for_slashed_queries() { + assert!(is_hf_spec("org/repo")); + assert!(!is_hf_spec("llama32_3b")); + } + + #[test] + fn selects_the_only_file_without_a_tag() { + let files = vec![file("model-Q8_0.gguf", 100)]; + let chosen = select_file(&spec("org/repo"), &files).expect("single file auto-selects"); + assert_eq!(chosen.filename, "model-Q8_0.gguf"); + } + + #[test] + fn multiple_files_without_a_tag_is_an_error_not_a_guess() { + let err = select_file(&spec("org/repo"), &ternary_bonsai()).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("add :"), + "error should teach the tag syntax: {message}" + ); + assert!( + message.contains("6 GGUF files"), + "mmproj companions must not count toward the choice: {message}" + ); + } + + #[test] + fn recognized_tag_prefers_the_exact_quant_label() { + let chosen = + select_file(&spec("org/repo:Q4_1"), &ternary_bonsai()).expect("unique Q4_1 row"); + assert_eq!(chosen.filename, "Ternary-Bonsai-27B-dspark-Q4_1.gguf"); + } + + #[test] + fn tags_match_on_token_boundaries_only() { + // :F16 must not match BF16 (or the excluded mmproj-BF16). + let chosen = select_file(&spec("org/repo:F16"), &ternary_bonsai()).expect("unique F16"); + assert_eq!(chosen.filename, "Ternary-Bonsai-27B-F16.gguf"); + // :Q2_0 must not match PQ2_0. + let chosen = select_file(&spec("org/repo:Q2_0"), &ternary_bonsai()).expect("unique Q2_0"); + assert_eq!(chosen.filename, "Ternary-Bonsai-27B-Q2_0.gguf"); + // Unrecognized labels are still addressable, case-insensitively. + let chosen = + select_file(&spec("org/repo:q2_g64"), &ternary_bonsai()).expect("unique Q2_g64"); + assert_eq!(chosen.filename, "Ternary-Bonsai-27B-Q2_g64.gguf"); + } + + #[test] + fn ambiguous_tag_is_an_error() { + let err = select_file(&spec("org/repo:Q2"), &ternary_bonsai()).unwrap_err(); + assert!( + err.to_string().contains("matches several files"), + "got: {err}" + ); + } + + #[test] + fn unmatched_tag_lists_the_choices() { + let err = select_file(&spec("org/repo:Q4_K_M"), &ternary_bonsai()).unwrap_err(); + assert!(err.to_string().contains("no file"), "got: {err}"); + } + + #[test] + fn mmproj_only_and_shard_only_repos_fail_closed() { + let mmproj_only = vec![file("model-mmproj-F16.gguf", 10)]; + let err = select_file(&spec("org/repo"), &mmproj_only).unwrap_err(); + assert!(err.to_string().contains("mmproj"), "got: {err}"); + + let shards = vec![ + file("model-Q4_0-00001-of-00002.gguf", 10), + file("model-Q4_0-00002-of-00002.gguf", 10), + ]; + let err = select_file(&spec("org/repo"), &shards).unwrap_err(); + assert!(err.to_string().contains("multi-part"), "got: {err}"); + } + + #[test] + fn shard_detection_requires_the_full_of_pattern() { + assert!(is_multipart_shard("m-00001-of-00003.gguf")); + assert!(is_multipart_shard("m-1-of-2.GGUF")); + assert!(!is_multipart_shard("m-Q4_0.gguf")); + assert!(!is_multipart_shard("best-of-nine.gguf")); + assert!(!is_multipart_shard("m-of-2.gguf")); + } + + #[test] + fn tag_matcher_is_boundary_and_case_aware() { + assert!(tag_matches("Model-Q2_0.gguf", "Q2_0")); + assert!(tag_matches("model-q2_g64.gguf", "Q2_G64")); + assert!(!tag_matches("Model-PQ2_0.gguf", "Q2_0")); + assert!(!tag_matches("Model-BF16.gguf", "F16")); + assert!(tag_matches("Model-BF16.gguf", "BF16")); + // `_` is a boundary, so a shorter tag stays ambiguous rather than wrong. + assert!(tag_matches("Model-Q4_K_M.gguf", "Q4_K")); + } + + /// The standard layout of the largest GGUF publisher: plain labels next to + /// `_L` / `Q4_0_x_y` superstring variants. The review round found the + /// original substring labeler collapsed these (Q6_K_L → "Q6_K"), making + /// plain tags ambiguous and superstring-only repos silently substitutable. + fn bartowski_layout() -> Vec { + vec![ + file("Llama-3.2-1B-Instruct-Q4_0.gguf", 100), + file("Llama-3.2-1B-Instruct-Q4_0_4_4.gguf", 100), + file("Llama-3.2-1B-Instruct-Q4_0_4_8.gguf", 100), + file("Llama-3.2-1B-Instruct-Q4_0_8_8.gguf", 100), + file("Llama-3.2-1B-Instruct-Q6_K.gguf", 100), + file("Llama-3.2-1B-Instruct-Q6_K_L.gguf", 100), + file("Llama-3.2-1B-Instruct-Q4_K_M.gguf", 100), + ] + } + + #[test] + fn exact_label_selects_even_with_superstring_variants() { + let files = bartowski_layout(); + for (tag, want) in [ + ("Q6_K", "Llama-3.2-1B-Instruct-Q6_K.gguf"), + ("Q6_K_L", "Llama-3.2-1B-Instruct-Q6_K_L.gguf"), + ("Q4_0", "Llama-3.2-1B-Instruct-Q4_0.gguf"), + ("Q4_0_8_8", "Llama-3.2-1B-Instruct-Q4_0_8_8.gguf"), + ] { + let chosen = select_file(&spec(&format!("org/repo:{tag}")), &files) + .unwrap_or_else(|err| panic!(":{tag} should select {want}: {err}")); + assert_eq!(chosen.filename, want, "for tag :{tag}"); + } + } + + #[test] + fn recognized_absent_tag_errors_instead_of_substituting() { + // :Q4_K in a repo shipping only Q4_K_M must never silently download + // the different quantization the user did not name. + let only_m = vec![file("model-Q4_K_M.gguf", 100)]; + let err = select_file(&spec("org/repo:Q4_K"), &only_m).unwrap_err(); + assert!(err.to_string().contains("has quant"), "got: {err}"); + + let only_l = vec![file("model-Q6_K_L.gguf", 100)]; + let err = select_file(&spec("org/repo:Q6_K"), &only_l).unwrap_err(); + assert!(err.to_string().contains("has quant"), "got: {err}"); + } + + #[test] + fn every_listed_tag_selects_exactly_its_file() { + // The listing is the CLI's own suggestion surface: a printed tag that + // errors (or picks a different file) is a dead end. Invariant-check the + // fixtures, including a duplicate-quant pair that forces the full-stem + // fallback. + let dup_quants = vec![ + file("model-alpha-Q8_0.gguf", 100), + file("model-beta-Q8_0.gguf", 100), + ]; + for files in [ternary_bonsai(), bartowski_layout(), dup_quants] { + let candidates: Vec<&HfGgufFile> = files + .iter() + .filter(|f| !is_mmproj(&f.filename) && !is_multipart_shard(&f.filename)) + .collect(); + for (tag, file) in listing_tags(&candidates) { + let chosen = + select_file(&spec(&format!("org/repo:{tag}")), &files).unwrap_or_else(|err| { + panic!("listed tag :{tag} must select {}: {err}", file.filename) + }); + assert_eq!(chosen.filename, file.filename, "for listed tag :{tag}"); + } + } + } + + #[test] + fn multibyte_specs_error_instead_of_panicking() { + // A multi-byte char straddling a URL-prefix byte length used to panic + // on a non-char-boundary slice (found by the security review lens). + for bad in ["hf.coü/x", "huggingface.c€/org/repo", "hf.c€xyz/a"] { + assert!( + HfModelSpec::parse(bad).is_err(), + "expected {bad:?} to be a clean parse error" + ); + } + } + + #[test] + fn dest_classification_never_overwrites() { + assert_eq!(classify_dest(None, 100), DestState::Missing); + assert_eq!(classify_dest(Some(0), 100), DestState::ReplaceEmpty); + assert_eq!(classify_dest(Some(100), 100), DestState::Complete); + assert_eq!(classify_dest(Some(7), 0), DestState::KeepUnknownSize); + assert_eq!(classify_dest(Some(0), 0), DestState::ReplaceEmpty); + assert_eq!(classify_dest(Some(7), 100), DestState::Conflict { have: 7 }); + assert_eq!( + classify_dest(Some(200), 100), + DestState::Conflict { have: 200 } + ); + } + + #[test] + fn ceiling_parse_falls_back_on_garbage_and_zero() { + let default = crate::api::DEFAULT_MAX_DOWNLOAD_BYTES; + assert_eq!(parse_max_download_bytes(None), default); + assert_eq!(parse_max_download_bytes(Some("0")), default); + assert_eq!(parse_max_download_bytes(Some("abc")), default); + assert_eq!(parse_max_download_bytes(Some("-1")), default); + assert_eq!(parse_max_download_bytes(Some(" 65536 ")), 65536); + } + + #[test] + fn windows_reserved_stems_are_rejected() { + for reserved in [ + "CON.gguf", + "con.gguf", + "NUL.gguf", + "COM1.gguf", + "lpt9.gguf", + "AUX.Q8_0.gguf", + ] { + assert!( + windows_reserved_stem(reserved), + "{reserved} should be reserved" + ); + } + for fine in [ + "CONX.gguf", + "model-CON.gguf", + "COM.gguf", + "COM10.gguf", + "tinyllama.gguf", + ] { + assert!(!windows_reserved_stem(fine), "{fine} should be allowed"); + } + } + + fn temp_models_dir(label: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "camelid-hfpull-test-{label}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn download_pre_gates_bail_before_any_bytes_move() { + let dir = temp_models_dir("gates"); + + // Filename gate (defense in depth: the listing filter already skips + // pathed names, this must hold even if a caller bypasses it). + let evil = file("e/vil.gguf", 10); + let err = download(&evil, &dir, 1000).unwrap_err(); + assert!(err.to_string().contains("bare *.gguf"), "got: {err}"); + + let reserved = file("CON.gguf", 10); + let err = download(&reserved, &dir, 1000).unwrap_err(); + assert!(err.to_string().contains("bare *.gguf"), "got: {err}"); + + // Ceiling gate. + let big = file("model-Q8_0.gguf", 2000); + let err = download(&big, &dir, 1000).unwrap_err(); + assert!(err.to_string().contains("download ceiling"), "got: {err}"); + + assert!(!dir.exists(), "pre-gates must not create the models dir"); + } + + #[test] + fn download_keeps_complete_and_refuses_conflicting_dest() { + let dir = temp_models_dir("dest"); + std::fs::create_dir_all(&dir).expect("create temp models dir"); + let dest = dir.join("model-Q8_0.gguf"); + + // Size-verified skip: no curl involved. + std::fs::write(&dest, b"12345").expect("seed dest"); + let f = file("model-Q8_0.gguf", 5); + let got = download(&f, &dir, 1000).expect("complete file is kept"); + assert_eq!(got, dest); + + // Same filename, different bytes: error, never overwrite. + let conflicting = file("model-Q8_0.gguf", 9); + let err = download(&conflicting, &dir, 1000).unwrap_err(); + assert!(err.to_string().contains("already exists"), "got: {err}"); + assert_eq!( + std::fs::read(&dest).expect("dest still readable"), + b"12345", + "conflicting pull must not touch the existing file" + ); + + // Unknown upstream size keeps the local copy. + let unknown = file("model-Q8_0.gguf", 0); + let got = download(&unknown, &dir, 1000).expect("unknown size keeps local copy"); + assert_eq!(got, dest); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn suggested_tags_cover_unrecognized_quants() { + assert_eq!(suggested_tag(&file("m-Q4_K_M.gguf", 1)), "Q4_K_M"); + assert_eq!( + suggested_tag(&file("Ternary-Bonsai-27B-PQ2_0.gguf", 1)), + "PQ2_0" + ); + assert_eq!( + suggested_tag(&file("Ternary-Bonsai-27B-Q2_g64.gguf", 1)), + "Q2_G64" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index bd4121b4..735c662f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod ghost_hot; pub mod ghost_install; pub mod grammar; pub mod hf_browse; +pub mod hf_pull; pub mod inference; pub mod kv_equivalence; pub mod metal; diff --git a/src/main.rs b/src/main.rs index 33db0a77..c747e800 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,6 +145,139 @@ mod ghost_moe_cli_tests { } } +#[cfg(test)] +mod hf_pull_cli_tests { + use super::*; + use std::path::PathBuf; + + /// Same rationale as `ghost_moe_cli_tests::on_cli_test_stack`: the Command + /// enum is large enough to overflow the default test-thread stack. These + /// tests additionally assert on env-attached args (CAMELID_MODEL fills + /// Serve/Chat `model`), so a developer's exported vars would make them + /// flaky: the vars are scrubbed first, under a mutex that serializes the + /// scrub-and-parse across this module's tests. + fn on_cli_test_stack(test: impl FnOnce() + Send + 'static) { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let guard = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + std::env::remove_var("CAMELID_MODEL"); + std::env::remove_var("CAMELID_MODELS_DIR"); + std::thread::Builder::new() + .name("hf-pull-cli-parse-test".into()) + .stack_size(8 * 1024 * 1024) + .spawn(test) + .expect("spawn CLI parse test") + .join() + .expect("CLI parse test panicked"); + drop(guard); + } + + #[test] + fn pull_accepts_hf_specs_and_dry_run() { + on_cli_test_stack(|| { + let cli = Cli::try_parse_from([ + "camelid", + "pull", + "prism-ml/Ternary-Bonsai-27B-gguf:Q2_0", + "--dry-run", + ]) + .expect("parse pull with an org/repo:quant spec"); + match cli.command { + Some(Command::Pull { model, dry_run, .. }) => { + assert_eq!( + model.as_deref(), + Some("prism-ml/Ternary-Bonsai-27B-gguf:Q2_0") + ); + assert!(dry_run); + } + other => panic!("expected Pull, got {other:?}"), + } + }); + } + + #[test] + fn pull_dry_run_defaults_off() { + on_cli_test_stack(|| { + let cli = + Cli::try_parse_from(["camelid", "pull", "llama32_3b"]).expect("parse curated pull"); + match cli.command { + Some(Command::Pull { dry_run, .. }) => assert!(!dry_run), + other => panic!("expected Pull, got {other:?}"), + } + }); + } + + #[test] + fn serve_parses_hf_spec() { + on_cli_test_stack(|| { + let cli = Cli::try_parse_from(["camelid", "serve", "--hf", "org/repo:Q8_0"]) + .expect("parse serve --hf"); + match cli.command { + Some(Command::Serve { model, hf, .. }) => { + assert_eq!(model, None); + assert_eq!(hf.as_deref(), Some("org/repo:Q8_0")); + } + other => panic!("expected Serve, got {other:?}"), + } + }); + } + + #[test] + fn serve_accepts_hf_alongside_model() { + // Deliberately NOT a clap conflict: an exported CAMELID_MODEL fills + // `model`, and a hard conflict would then make --hf unusable. Both + // parse; the dispatch arm gives the typed --hf precedence. + on_cli_test_stack(|| { + let cli = Cli::try_parse_from([ + "camelid", + "serve", + "--hf", + "org/repo:Q8_0", + "--model", + "models/a.gguf", + ]) + .expect("parse serve with both --hf and --model"); + match cli.command { + Some(Command::Serve { model, hf, .. }) => { + assert_eq!(model, Some(PathBuf::from("models/a.gguf"))); + assert_eq!(hf.as_deref(), Some("org/repo:Q8_0")); + } + other => panic!("expected Serve, got {other:?}"), + } + }); + } + + #[test] + fn chat_parses_hf_spec() { + on_cli_test_stack(|| { + let cli = Cli::try_parse_from(["camelid", "chat", "--hf", "org/repo"]) + .expect("parse chat --hf"); + match cli.command { + Some(Command::Chat { model, hf, .. }) => { + assert_eq!(model, None); + assert_eq!(hf.as_deref(), Some("org/repo")); + } + other => panic!("expected Chat, got {other:?}"), + } + }); + } + + #[test] + fn serve_defaults_leave_hf_unset() { + on_cli_test_stack(|| { + let cli = Cli::try_parse_from(["camelid", "serve"]).expect("parse bare serve"); + match cli.command { + Some(Command::Serve { hf, model, .. }) => { + assert_eq!(hf, None); + assert_eq!(model, None::); + } + other => panic!("expected Serve, got {other:?}"), + } + }); + } +} + use camelid::{ api, chat, cluster::{ @@ -197,6 +330,9 @@ fn default_launch_command() -> Command { Command::Serve { addr: "127.0.0.1:8181".parse().expect("valid default serve addr"), model: std::env::var_os("CAMELID_MODEL").map(PathBuf::from), + // Double-click launches never auto-download (--hf is CLI-only by design: + // no env alias exists that could move gigabytes on app open). + hf: None, threads: None, parallel_linear_min_outputs: None, apple_accelerate_min_elements: None, @@ -823,6 +959,15 @@ enum Command { /// Load a GGUF model at startup and auto-select the safest validated execution plan. #[arg(long, env = "CAMELID_MODEL")] model: Option, + /// Download (if missing) and serve a Hugging Face GGUF by + /// `org/repo[:quant]` spec — `camelid pull` plus `--model ` in one + /// step. Experimental lane: the file is unverified, carries no parity + /// claim, and still fails closed at load if unsupported. Deliberately + /// CLI-only (no env alias) and not a clap conflict with --model: an + /// exported CAMELID_MODEL must not make this flag unusable, so a typed + /// --hf simply takes precedence at dispatch. + #[arg(long, value_name = "ORG/REPO[:QUANT]")] + hf: Option, /// Override Rayon worker threads for the inference server. #[arg(long, env = "CAMELID_THREADS")] threads: Option, @@ -925,6 +1070,13 @@ enum Command { /// open the supported-model picker. #[arg(long, env = "CAMELID_MODEL")] model: Option, + /// Download (if missing) a Hugging Face GGUF by `org/repo[:quant]` spec + /// and load it at startup — `camelid pull` plus `--model ` in one + /// step. Experimental lane: unverified, no parity claim, fails closed at + /// load if unsupported. Not a clap conflict with --model (an exported + /// CAMELID_MODEL would make it unusable); a typed --hf wins at dispatch. + #[arg(long, value_name = "ORG/REPO[:QUANT]")] + hf: Option, /// Server to attach to, or spawn on if nothing is listening there. #[arg(long, default_value = "127.0.0.1:8181", env = "CAMELID_ADDR")] addr: SocketAddr, @@ -1193,16 +1345,25 @@ enum Command { #[arg(long)] safety_mb: Option, }, - /// Download a supported model (a known-good Q8_0 GGUF) into ./models. + /// Download a model into ./models: a curated known-good row, or any public + /// Hugging Face GGUF by `org/repo[:quant]` spec. /// - /// Run with no argument to list the catalog. Accepts a catalog id or a - /// fragment of the name, e.g. `camelid pull llama32_3b`. + /// Run with no argument to list the curated catalog. Accepts a catalog id + /// or a fragment of the name, e.g. `camelid pull llama32_3b` — or a Hugging + /// Face spec (experimental lane: unverified, no parity claim; a download + /// path is not a support claim), e.g. + /// `camelid pull prism-ml/Ternary-Bonsai-27B-gguf:Q2_0`. Pull { - /// Catalog id or name fragment to download. Omit to list all models. + /// Catalog id, name fragment, or Hugging Face `org/repo[:quant]` spec. + /// Omit to list the curated catalog. model: Option, /// Directory to download into (default: ./models). #[arg(long, env = "CAMELID_MODELS_DIR")] models_dir: Option, + /// Resolve and print what would be downloaded, then exit without + /// downloading. + #[arg(long, default_value_t = false)] + dry_run: bool, }, /// Generate text with a Gemma 4 model (correctness-first runtime). Gemma4Generate { @@ -1904,6 +2065,7 @@ async fn main() -> anyhow::Result<()> { Command::Serve { addr, model, + hf, threads, parallel_linear_min_outputs, apple_accelerate_min_elements, @@ -1980,10 +2142,33 @@ async fn main() -> anyhow::Result<()> { } // Open-and-use launch: if no model was named, load the user's saved // default from the configured model library. With no saved choice, - // the first local GGUF is the zero-configuration default. - let model = match model { - Some(path) => Some(api::StartupModel::explicit(path)), - None => { + // the first local GGUF is the zero-configuration default. A typed + // --hf wins over --model/CAMELID_MODEL (see the flag's doc comment). + let model = match (model, hf) { + (shadowed, Some(spec)) => { + if shadowed.is_some() { + eprintln!( + "note: --hf takes precedence over --model/CAMELID_MODEL for this run" + ); + } + // Pull-if-missing before the server starts, into the SAME + // directory the server will scan (api::resolve_models_dir is + // the server's own default resolution — exe-dir models/ in + // the shipped layout, else ./models), so the file always + // appears in the Models page. The user named this model, so + // a failure here is fatal, exactly like an explicit --model + // load. The serve --max-download-bytes flag governs this + // download too. + let dir = api::resolve_models_dir(models_dir.clone()); + let path = camelid::hf_pull::ensure_hf_model( + &spec, + &dir, + Some(server.max_download_bytes), + )?; + Some(api::StartupModel::explicit(path)) + } + (Some(path), None) => Some(api::StartupModel::explicit(path)), + (None, None) => { auto_select_model(models_dir.as_deref()).map(api::StartupModel::auto_selected) } }; @@ -2019,6 +2204,7 @@ async fn main() -> anyhow::Result<()> { } Command::Chat { model, + hf, addr, system, max_tokens, @@ -2042,6 +2228,21 @@ async fn main() -> anyhow::Result<()> { audit_webhook, shell_sandbox, } => { + let models_dir = models_dir.unwrap_or_else(|| PathBuf::from("models")); + // `--hf` resolves to a local file up front (pull-if-missing), then + // rides the ordinary `--model` load path. A typed --hf wins over + // --model/CAMELID_MODEL, mirroring serve. + let model = match (model, hf) { + (shadowed, Some(spec)) => { + if shadowed.is_some() { + eprintln!( + "note: --hf takes precedence over --model/CAMELID_MODEL for this run" + ); + } + Some(camelid::hf_pull::ensure_hf_model(&spec, &models_dir, None)?) + } + (path, None) => path, + }; let code = chat::run_chat(chat::ChatOptions { model, addr, @@ -2053,7 +2254,7 @@ async fn main() -> anyhow::Result<()> { seed, no_stream, plain, - models_dir: models_dir.unwrap_or_else(|| PathBuf::from("models")), + models_dir, exec_goal: None, agent, workdir, @@ -2403,9 +2604,13 @@ async fn main() -> anyhow::Result<()> { .collect(); println!("[offload] layer map (V=VRAM, H=host): {map}"); } - Command::Pull { model, models_dir } => { + Command::Pull { + model, + models_dir, + dry_run, + } => { let dir = models_dir.unwrap_or_else(|| PathBuf::from("models")); - camelid::catalog::run_pull(model.as_deref(), &dir)?; + camelid::catalog::run_pull_opts(model.as_deref(), &dir, dry_run)?; } Command::Gemma4Generate { path,