Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
>
Expand All @@ -99,7 +107,7 @@ Good starting points:

### Full `camelid pull` catalog

Run `camelid pull <id>` 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 <id>` 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 |
|---|---|---|---:|---|---|
Expand Down
8 changes: 8 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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>) -> PathBuf {
pub fn resolve_models_dir(configured: Option<PathBuf>) -> PathBuf {
let dir = configured.unwrap_or_else(default_models_dir);
if dir.is_absolute() {
dir
Expand Down
39 changes: 39 additions & 0 deletions src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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());
Expand Down Expand Up @@ -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");
}
}
125 changes: 103 additions & 22 deletions src/hf_browse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,16 +257,28 @@ fn fetch_repo_files_parallel(repos: &[RepoMeta]) -> Vec<HfGgufFile> {
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<Vec<HfGgufFile>> {
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/<filename>` 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<Vec<HfGgufFile>> {
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<Vec<HfGgufFile>> {
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}"))?;
Expand All @@ -292,12 +304,12 @@ fn repo_gguf_files(repo: &RepoMeta) -> anyhow::Result<Vec<HfGgufFile>> {
.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(),
});
}
Expand Down Expand Up @@ -381,7 +393,9 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
}

/// 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 {
Expand Down Expand Up @@ -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<String> {
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<String> {
QUANT_PATTERNS
.iter()
.find(|p| upper.contains(**p))
.find(|p| token_bounded_contains(filename, p))
.map(|p| (*p).to_string())
}

Expand Down Expand Up @@ -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!(
Expand Down
Loading