mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-21 06:33:45 +00:00
Merge pull request #860 from ruvnet/fix/pir-wp0b-gguf-download
fix(ruvllm-cli): resolve GGUF globs via HF file listing + alias routing (PIR WP0b)
This commit is contained in:
commit
33ad5d4345
5 changed files with 613 additions and 41 deletions
|
|
@ -49,9 +49,11 @@ pub async fn run(
|
|||
draft_model: Option<&str>,
|
||||
speculative_lookahead: usize,
|
||||
) -> Result<()> {
|
||||
let model_id = resolve_model_id(model);
|
||||
let quant = QuantPreset::from_str(quantization)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid quantization format: {}", quantization))?;
|
||||
// Resolve to the repo that hosts the weights for this quantization
|
||||
// (GGUF twin for safetensors-only aliases) — must match `download`'s cache key.
|
||||
let model_id = crate::models::resolve_weights_repo(model, quant);
|
||||
|
||||
// Print header
|
||||
print_header(&model_id, system_prompt, max_tokens, temperature);
|
||||
|
|
@ -80,7 +82,11 @@ pub async fn run(
|
|||
"{}",
|
||||
"Loading draft model for speculative decoding...".yellow()
|
||||
);
|
||||
let draft = load_model(&resolve_model_id(draft_id), quant, cache_dir)?;
|
||||
let draft = load_model(
|
||||
&crate::models::resolve_weights_repo(draft_id, quant),
|
||||
quant,
|
||||
cache_dir,
|
||||
)?;
|
||||
|
||||
if let Some(info) = draft.model_info() {
|
||||
println!(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use hf_hub::{Repo, RepoType};
|
|||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::{get_model, resolve_model_id, QuantPreset};
|
||||
use crate::models::{get_model, resolve_model_id, resolve_weights_repo, QuantPreset};
|
||||
|
||||
/// Run the download command
|
||||
pub async fn run(
|
||||
|
|
@ -22,10 +22,14 @@ pub async fn run(
|
|||
revision: Option<&str>,
|
||||
cache_dir: &str,
|
||||
) -> Result<()> {
|
||||
let model_id = resolve_model_id(model);
|
||||
let quant = QuantPreset::from_str(quantization)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid quantization format: {}", quantization))?;
|
||||
|
||||
// Route quantized (GGUF) requests to the repo that actually hosts GGUF
|
||||
// weights (registry `gguf_repo` twin for safetensors-only aliases).
|
||||
let base_id = resolve_model_id(model);
|
||||
let model_id = resolve_weights_repo(model, quant);
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{} {} ({})",
|
||||
|
|
@ -33,6 +37,13 @@ pub async fn run(
|
|||
model_id,
|
||||
quant
|
||||
);
|
||||
if model_id != base_id {
|
||||
println!(
|
||||
" {} {} hosts safetensors only; using GGUF twin repo",
|
||||
"Note:".dimmed(),
|
||||
base_id
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Get model info if available
|
||||
|
|
@ -62,8 +73,12 @@ pub async fn run(
|
|||
api.repo(Repo::new(model_id.clone(), RepoType::Model))
|
||||
};
|
||||
|
||||
// Determine files to download
|
||||
let files_to_download = get_files_to_download(&model_id, quant);
|
||||
// List the repo's actual files and expand the quant pattern against them
|
||||
// (previously an unexpanded glob like "*Q4_K_M.gguf" was sent literally -> 404).
|
||||
let remote_files = list_repo_files(&repo, &model_id, revision)
|
||||
.await
|
||||
.with_context(|| format!("Failed to list files for {model_id} on HuggingFace"))?;
|
||||
let files_to_download = get_files_to_download(&model_id, quant, &remote_files)?;
|
||||
|
||||
// Create cache directory
|
||||
let model_cache_dir = PathBuf::from(cache_dir).join("models").join(&model_id);
|
||||
|
|
@ -73,7 +88,20 @@ pub async fn run(
|
|||
|
||||
// Download each file
|
||||
for file_name in &files_to_download {
|
||||
// Defense-in-depth: `file_name` originates from the repo's own file
|
||||
// listing (HF tree API / siblings) and is attacker-controlled for a
|
||||
// malicious repo — reject traversal before joining into the cache.
|
||||
validate_remote_file_name(file_name)
|
||||
.with_context(|| format!("Unsafe file path in {model_id} listing"))?;
|
||||
let target_path = model_cache_dir.join(file_name);
|
||||
if let Some(parent) = target_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create cache subdirectory")?;
|
||||
ensure_under_cache_dir(&model_cache_dir, parent).with_context(|| {
|
||||
format!("Refusing to write {file_name} outside the model cache directory")
|
||||
})?;
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if target_path.exists() && !force {
|
||||
|
|
@ -174,43 +202,280 @@ fn download_via_curl(model_id: &str, revision: Option<&str>, file_name: &str) ->
|
|||
"-o".to_string(),
|
||||
out.to_string_lossy().to_string(),
|
||||
];
|
||||
if let Ok(token) = std::env::var("HF_TOKEN") {
|
||||
args.push("-H".to_string());
|
||||
args.push(format!("Authorization: Bearer {token}"));
|
||||
let auth_config = hf_token_curl_config();
|
||||
if auth_config.is_some() {
|
||||
args.push("--config".to_string());
|
||||
args.push("-".to_string());
|
||||
}
|
||||
args.push(url.clone());
|
||||
let status = std::process::Command::new("curl")
|
||||
.args(&args)
|
||||
.status()
|
||||
let output = run_curl(&args, auth_config.as_deref())
|
||||
.with_context(|| format!("curl not available to fetch {url}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("curl fallback failed ({status}) for {url}");
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("curl fallback failed ({}) for {url}", output.status);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Get list of files to download for a model and quantization
|
||||
fn get_files_to_download(model_id: &str, quant: QuantPreset) -> Vec<String> {
|
||||
let mut files = vec![
|
||||
"tokenizer.json".to_string(),
|
||||
"tokenizer_config.json".to_string(),
|
||||
"config.json".to_string(),
|
||||
];
|
||||
/// List a repo's files via the hf-hub API, falling back to a `curl` of the
|
||||
/// HF tree endpoint (`GET /api/models/<id>/tree/<rev>`) — the same fallback
|
||||
/// idiom as `download_via_curl`, honoring `HF_TOKEN`.
|
||||
async fn list_repo_files(
|
||||
repo: &hf_hub::api::tokio::ApiRepo,
|
||||
model_id: &str,
|
||||
revision: Option<&str>,
|
||||
) -> Result<Vec<String>> {
|
||||
match repo.info().await {
|
||||
Ok(info) => Ok(info.siblings.into_iter().map(|s| s.rfilename).collect()),
|
||||
Err(e) => list_files_via_curl(model_id, revision)
|
||||
.with_context(|| format!("hf-hub file listing also failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Add model weights based on quantization
|
||||
if model_id.contains("GGUF") || quant != QuantPreset::None {
|
||||
// Look for GGUF files
|
||||
files.push(format!("*{}", quant.gguf_suffix()));
|
||||
} else {
|
||||
// SafeTensors format
|
||||
/// Fallback file listing via `curl` against the HF tree API.
|
||||
fn list_files_via_curl(model_id: &str, revision: Option<&str>) -> Result<Vec<String>> {
|
||||
let rev = revision.unwrap_or("main");
|
||||
let url = format!("https://huggingface.co/api/models/{model_id}/tree/{rev}?recursive=true");
|
||||
let mut args = vec!["-L".to_string(), "--fail".to_string(), "-sS".to_string()];
|
||||
let auth_config = hf_token_curl_config();
|
||||
if auth_config.is_some() {
|
||||
args.push("--config".to_string());
|
||||
args.push("-".to_string());
|
||||
}
|
||||
args.push(url.clone());
|
||||
let output = run_curl(&args, auth_config.as_deref())
|
||||
.with_context(|| format!("curl not available to list {url}"))?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("curl file listing failed ({}) for {url}", output.status);
|
||||
}
|
||||
parse_tree_file_paths(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// Build the curl-config fragment carrying the `HF_TOKEN` Authorization
|
||||
/// header, or `None` when the token is unset. Passing the header through a
|
||||
/// config read from stdin (`--config -`) keeps the token off curl's argv,
|
||||
/// which is world-readable via `ps`/`/proc/<pid>/cmdline` (CWE-214).
|
||||
fn hf_token_curl_config() -> Option<String> {
|
||||
std::env::var("HF_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| curl_auth_config(&t))
|
||||
}
|
||||
|
||||
/// Render an Authorization header as a curl config line, escaping the
|
||||
/// characters curl's double-quoted config strings treat specially so a
|
||||
/// hostile/odd token value cannot inject additional config directives.
|
||||
fn curl_auth_config(token: &str) -> String {
|
||||
let escaped = token
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r");
|
||||
format!("header = \"Authorization: Bearer {escaped}\"\n")
|
||||
}
|
||||
|
||||
/// Run `curl` with `args`, feeding `stdin_config` (a curl config carrying the
|
||||
/// auth header) on stdin when present. Stderr is inherited so `-sS` errors
|
||||
/// stay visible; stdout is captured for callers that parse it.
|
||||
fn run_curl(args: &[String], stdin_config: Option<&str>) -> Result<std::process::Output> {
|
||||
use std::io::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut cmd = Command::new("curl");
|
||||
cmd.args(args)
|
||||
.stdin(if stdin_config.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
})
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
let mut child = cmd.spawn().context("failed to spawn curl")?;
|
||||
if let Some(config) = stdin_config {
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("stdin is piped when a config is supplied")
|
||||
.write_all(config.as_bytes())
|
||||
.context("failed to write curl config to stdin")?;
|
||||
// stdin handle dropped here -> EOF for `--config -`
|
||||
}
|
||||
child.wait_with_output().context("failed to wait for curl")
|
||||
}
|
||||
|
||||
/// Defense-in-depth guard for file names taken from a repo's own listing
|
||||
/// (HF tree API `path` / `siblings[].rfilename`): reject empty names and any
|
||||
/// component that is not a plain path segment (absolute paths, `..`, `.`,
|
||||
/// Windows prefixes) before the name is joined into the cache directory.
|
||||
fn validate_remote_file_name(file_name: &str) -> Result<()> {
|
||||
use std::path::Component;
|
||||
|
||||
if file_name.is_empty() {
|
||||
anyhow::bail!("empty file name in repo listing");
|
||||
}
|
||||
if Path::new(file_name)
|
||||
.components()
|
||||
.any(|c| !matches!(c, Component::Normal(_)))
|
||||
{
|
||||
anyhow::bail!("suspicious file path in repo listing: {file_name:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify that `target_parent` (already created) canonicalizes to a location
|
||||
/// under the canonicalized model cache dir — catches anything the component
|
||||
/// check missed (e.g. symlinked subdirectories escaping the cache).
|
||||
fn ensure_under_cache_dir(cache_root: &Path, target_parent: &Path) -> Result<()> {
|
||||
let root = cache_root
|
||||
.canonicalize()
|
||||
.context("failed to canonicalize model cache directory")?;
|
||||
let parent = target_parent
|
||||
.canonicalize()
|
||||
.context("failed to canonicalize download target directory")?;
|
||||
if !parent.starts_with(&root) {
|
||||
anyhow::bail!(
|
||||
"download target {} escapes model cache directory {}",
|
||||
parent.display(),
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse file paths out of the HF tree API JSON (`[{"type":"file","path":...},...]`).
|
||||
fn parse_tree_file_paths(json: &str) -> Result<Vec<String>> {
|
||||
let entries: serde_json::Value =
|
||||
serde_json::from_str(json).context("Invalid JSON from HF tree API")?;
|
||||
let entries = entries
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("HF tree API did not return an array"))?;
|
||||
Ok(entries
|
||||
.iter()
|
||||
.filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("file"))
|
||||
.filter_map(|e| e.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get list of files to download for a model and quantization, resolved
|
||||
/// against the repo's actual file listing.
|
||||
fn get_files_to_download(
|
||||
model_id: &str,
|
||||
quant: QuantPreset,
|
||||
remote_files: &[String],
|
||||
) -> Result<Vec<String>> {
|
||||
// Aux files: only the ones the repo actually has (GGUF repos typically
|
||||
// ship none — tokenizer/config are embedded in the GGUF itself).
|
||||
let mut files: Vec<String> = [
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"config.json",
|
||||
"special_tokens_map.json",
|
||||
"generation_config.json",
|
||||
]
|
||||
.iter()
|
||||
.filter(|f| remote_files.iter().any(|r| r == *f))
|
||||
.map(|f| f.to_string())
|
||||
.collect();
|
||||
|
||||
// Model weights
|
||||
if model_id.to_ascii_uppercase().contains("GGUF") || quant != QuantPreset::None {
|
||||
files.extend(select_gguf_files(model_id, quant, remote_files)?);
|
||||
} else if remote_files.iter().any(|f| f == "model.safetensors") {
|
||||
files.push("model.safetensors".to_string());
|
||||
} else {
|
||||
// Sharded safetensors (model-00001-of-000NN.safetensors)
|
||||
let mut shards: Vec<String> = remote_files
|
||||
.iter()
|
||||
.filter(|f| f.starts_with("model-") && f.ends_with(".safetensors"))
|
||||
.cloned()
|
||||
.collect();
|
||||
if shards.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No safetensors weights found in {model_id}.\nAvailable files:\n {}",
|
||||
remote_files.join("\n ")
|
||||
);
|
||||
}
|
||||
shards.sort();
|
||||
files.extend(shards);
|
||||
}
|
||||
|
||||
// Add special tokens and chat template if available
|
||||
files.push("special_tokens_map.json".to_string());
|
||||
files.push("generation_config.json".to_string());
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
files
|
||||
/// Select the GGUF weight file(s) matching `quant` from the repo listing.
|
||||
///
|
||||
/// Handles both single-file (`...-Q4_K_M.gguf`) and multi-part
|
||||
/// (`...-q4_k_m-00001-of-00003.gguf`) layouts — multi-part is the norm for
|
||||
/// larger models (e.g. Qwen2.5-14B), so all parts are downloaded in order.
|
||||
/// Fails with the repo's actual GGUF inventory (or full file list) when
|
||||
/// nothing matches, instead of 404ing on a glob.
|
||||
fn select_gguf_files(
|
||||
model_id: &str,
|
||||
quant: QuantPreset,
|
||||
remote_files: &[String],
|
||||
) -> Result<Vec<String>> {
|
||||
let mut matches: Vec<String> = remote_files
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
quant
|
||||
.gguf_tags()
|
||||
.iter()
|
||||
.any(|tag| matches_gguf_quant(f, tag))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
let ggufs: Vec<&String> = remote_files
|
||||
.iter()
|
||||
.filter(|f| f.to_ascii_lowercase().ends_with(".gguf"))
|
||||
.collect();
|
||||
if ggufs.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No GGUF files found in {model_id} (requested quantization: {quant}).\n\
|
||||
This repo appears to host non-GGUF weights only. Available files:\n {}\n\
|
||||
Hint: pass a GGUF repo, or use --quantization none for safetensors.",
|
||||
remote_files.join("\n ")
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No GGUF file matching quantization {quant} in {model_id}.\n\
|
||||
Available GGUF files:\n {}",
|
||||
ggufs
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
// Zero-padded part numbers sort lexically (…-00001-of-00003 first).
|
||||
matches.sort();
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// True if `file` is a GGUF weight file for the given lowercase quant tag,
|
||||
/// either single-file (`<name>-<tag>.gguf`) or a multi-part shard
|
||||
/// (`<name>-<tag>-NNNNN-of-NNNNN.gguf`). Matching is case-insensitive.
|
||||
fn matches_gguf_quant(file: &str, tag: &str) -> bool {
|
||||
let f = file.to_ascii_lowercase();
|
||||
let Some(stem) = f.strip_suffix(".gguf") else {
|
||||
return false;
|
||||
};
|
||||
// Single-file: ends with the tag (allow "-tag", ".tag", or bare tag)
|
||||
if stem == tag || stem.ends_with(&format!("-{tag}")) || stem.ends_with(&format!(".{tag}")) {
|
||||
return true;
|
||||
}
|
||||
// Multi-part: ...-<tag>-NNNNN-of-NNNNN
|
||||
if let Some(idx) = stem.rfind(&format!("-{tag}-")) {
|
||||
let rest = &stem[idx + tag.len() + 2..];
|
||||
if let Some((part, total)) = rest.split_once("-of-") {
|
||||
return !part.is_empty()
|
||||
&& !total.is_empty()
|
||||
&& part.chars().all(|c| c.is_ascii_digit())
|
||||
&& total.chars().all(|c| c.is_ascii_digit());
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a model is already downloaded
|
||||
|
|
@ -243,10 +508,220 @@ pub fn get_model_path(model: &str, cache_dir: &str) -> PathBuf {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// HF tree API fixture: Qwen-style GGUF repo (lowercase, multi-part).
|
||||
const QWEN_TREE_JSON: &str = r#"[
|
||||
{"type":"file","oid":"a","size":100,"path":".gitattributes"},
|
||||
{"type":"file","oid":"b","size":100,"path":"README.md"},
|
||||
{"type":"directory","oid":"c","path":"assets"},
|
||||
{"type":"file","oid":"d","size":1,"path":"qwen2.5-14b-instruct-q4_k_m-00002-of-00003.gguf"},
|
||||
{"type":"file","oid":"e","size":1,"path":"qwen2.5-14b-instruct-q4_k_m-00001-of-00003.gguf"},
|
||||
{"type":"file","oid":"f","size":1,"path":"qwen2.5-14b-instruct-q4_k_m-00003-of-00003.gguf"},
|
||||
{"type":"file","oid":"g","size":1,"path":"qwen2.5-14b-instruct-q8_0-00001-of-00004.gguf"},
|
||||
{"type":"file","oid":"h","size":1,"path":"qwen2.5-14b-instruct-fp16-00001-of-00008.gguf"}
|
||||
]"#;
|
||||
|
||||
fn qwen_files() -> Vec<String> {
|
||||
parse_tree_file_paths(QWEN_TREE_JSON).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_to_download() {
|
||||
let files = get_files_to_download("test/model", QuantPreset::Q4K);
|
||||
fn test_parse_tree_file_paths_skips_directories() {
|
||||
let files = qwen_files();
|
||||
assert_eq!(files.len(), 7);
|
||||
assert!(!files.iter().any(|f| f == "assets"));
|
||||
assert!(files.contains(&"README.md".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tree_rejects_bad_json() {
|
||||
assert!(parse_tree_file_paths("not json").is_err());
|
||||
assert!(parse_tree_file_paths(r#"{"error":"Repo not found"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_expansion_multi_part_sorted() {
|
||||
// The old code pushed the literal "*Q4_K_M.gguf" -> 404. The matcher
|
||||
// must instead select the real (multi-part, lowercase) filenames.
|
||||
let files = select_gguf_files(
|
||||
"Qwen/Qwen2.5-14B-Instruct-GGUF",
|
||||
QuantPreset::Q4K,
|
||||
&qwen_files(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
files,
|
||||
vec![
|
||||
"qwen2.5-14b-instruct-q4_k_m-00001-of-00003.gguf",
|
||||
"qwen2.5-14b-instruct-q4_k_m-00002-of-00003.gguf",
|
||||
"qwen2.5-14b-instruct-q4_k_m-00003-of-00003.gguf",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_expansion_single_file_uppercase() {
|
||||
// bartowski-style single-file uppercase naming
|
||||
let files = vec![
|
||||
"README.md".to_string(),
|
||||
"microsoft_Phi-4-mini-instruct-Q4_K_M.gguf".to_string(),
|
||||
"microsoft_Phi-4-mini-instruct-Q8_0.gguf".to_string(),
|
||||
];
|
||||
let selected = select_gguf_files(
|
||||
"bartowski/microsoft_Phi-4-mini-instruct-GGUF",
|
||||
QuantPreset::Q4K,
|
||||
&files,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected, vec!["microsoft_Phi-4-mini-instruct-Q4_K_M.gguf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_f16_matches_fp16_spelling() {
|
||||
let selected = select_gguf_files(
|
||||
"Qwen/Qwen2.5-14B-Instruct-GGUF",
|
||||
QuantPreset::F16,
|
||||
&qwen_files(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected,
|
||||
vec!["qwen2.5-14b-instruct-fp16-00001-of-00008.gguf"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_gguf_in_repo_fails_with_available_files() {
|
||||
// Safetensors-only repo (the phi/microsoft case) must fail early with
|
||||
// an actionable listing, not a 404 on a glob.
|
||||
let files = vec![
|
||||
"config.json".to_string(),
|
||||
"model-00001-of-00002.safetensors".to_string(),
|
||||
"model-00002-of-00002.safetensors".to_string(),
|
||||
];
|
||||
let err = select_gguf_files("microsoft/Phi-4-mini-instruct", QuantPreset::Q4K, &files)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("No GGUF files found"));
|
||||
assert!(err.contains("model-00001-of-00002.safetensors"));
|
||||
assert!(err.contains("--quantization none"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_quant_lists_available_ggufs() {
|
||||
let files = vec!["m-q8_0.gguf".to_string()];
|
||||
let err = select_gguf_files("x/y-GGUF", QuantPreset::Q4K, &files)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("Q4_K_M"));
|
||||
assert!(err.contains("m-q8_0.gguf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_gguf_quant_shapes() {
|
||||
assert!(matches_gguf_quant("model-Q4_K_M.gguf", "q4_k_m"));
|
||||
assert!(matches_gguf_quant("model.q4_k_m.gguf", "q4_k_m"));
|
||||
assert!(matches_gguf_quant("m-q4_k_m-00001-of-00003.gguf", "q4_k_m"));
|
||||
// No false positives on other quants or non-gguf files
|
||||
assert!(!matches_gguf_quant("model-Q4_K_S.gguf", "q4_k_m"));
|
||||
assert!(!matches_gguf_quant("model-q4_k_m.safetensors", "q4_k_m"));
|
||||
assert!(!matches_gguf_quant("m-q4_k_m-partial-of-x.gguf", "q4_k_m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_to_download_filters_aux_by_listing() {
|
||||
// GGUF repos ship no tokenizer.json/config.json — must not request them.
|
||||
let files = get_files_to_download(
|
||||
"Qwen/Qwen2.5-14B-Instruct-GGUF",
|
||||
QuantPreset::Q4K,
|
||||
&qwen_files(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!files.contains(&"tokenizer.json".to_string()));
|
||||
assert!(
|
||||
files.iter().all(|f| !f.contains('*')),
|
||||
"no unexpanded globs"
|
||||
);
|
||||
assert_eq!(files.iter().filter(|f| f.ends_with(".gguf")).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_remote_file_name_rejects_hostile_names() {
|
||||
for hostile in [
|
||||
"../x",
|
||||
"/abs",
|
||||
"a/../../x",
|
||||
"",
|
||||
"..",
|
||||
"./x/../y",
|
||||
"/etc/passwd",
|
||||
] {
|
||||
assert!(
|
||||
validate_remote_file_name(hostile).is_err(),
|
||||
"should reject {hostile:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_remote_file_name_accepts_normal_names() {
|
||||
for ok in [
|
||||
"model-Q4_K_M.gguf",
|
||||
"tokenizer.json",
|
||||
"subdir/model-00001-of-00003.gguf",
|
||||
"a/b/c.txt",
|
||||
] {
|
||||
assert!(
|
||||
validate_remote_file_name(ok).is_ok(),
|
||||
"should accept {ok:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_under_cache_dir_containment() {
|
||||
let base = std::env::temp_dir().join(format!("ruvllm-guard-test-{}", std::process::id()));
|
||||
let root = base.join("cache");
|
||||
let inside = root.join("sub");
|
||||
let outside = base.join("outside");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
|
||||
assert!(ensure_under_cache_dir(&root, &inside).is_ok());
|
||||
assert!(ensure_under_cache_dir(&root, &root).is_ok());
|
||||
assert!(ensure_under_cache_dir(&root, &outside).is_err());
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_curl_auth_config_keeps_token_in_header_line() {
|
||||
let cfg = curl_auth_config("hf_abc123");
|
||||
assert_eq!(cfg, "header = \"Authorization: Bearer hf_abc123\"\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_curl_auth_config_escapes_special_characters() {
|
||||
// Quotes, backslashes, and newlines must not break out of the quoted
|
||||
// config string (which would allow injecting extra curl directives).
|
||||
let cfg = curl_auth_config("a\"b\\c\nd\re");
|
||||
assert_eq!(
|
||||
cfg,
|
||||
"header = \"Authorization: Bearer a\\\"b\\\\c\\nd\\re\"\n"
|
||||
);
|
||||
// Exactly one config line regardless of token content.
|
||||
assert_eq!(cfg.matches('\n').count(), 1);
|
||||
assert!(cfg.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_to_download_safetensors_path() {
|
||||
let remote = vec![
|
||||
"tokenizer.json".to_string(),
|
||||
"config.json".to_string(),
|
||||
"model.safetensors".to_string(),
|
||||
];
|
||||
let files = get_files_to_download("test/model", QuantPreset::None, &remote).unwrap();
|
||||
assert!(files.contains(&"tokenizer.json".to_string()));
|
||||
assert!(files.iter().any(|f| f.contains("Q4_K_M")));
|
||||
assert!(files.contains(&"model.safetensors".to_string()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,11 @@ pub async fn run(
|
|||
quantization: &str,
|
||||
cache_dir: &str,
|
||||
) -> Result<()> {
|
||||
let model_id = resolve_model_id(model);
|
||||
let quant = QuantPreset::from_str(quantization)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid quantization format: {}", quantization))?;
|
||||
// Resolve to the repo that hosts the weights for this quantization
|
||||
// (GGUF twin for safetensors-only aliases) — must match `download`'s cache key.
|
||||
let model_id = crate::models::resolve_weights_repo(model, quant);
|
||||
|
||||
println!();
|
||||
println!("{}", style("RuvLLM Inference Server").bold().cyan());
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ pub struct ModelDefinition {
|
|||
pub context_length: usize,
|
||||
/// Notes about the model
|
||||
pub notes: String,
|
||||
/// GGUF "twin" repo for models whose `hf_id` only hosts safetensors.
|
||||
/// When a quantized (GGUF) download is requested, weights are resolved
|
||||
/// from this repo instead of `hf_id`. `None` means `hf_id` itself hosts
|
||||
/// the GGUF files (or no GGUF twin is known).
|
||||
#[serde(default)]
|
||||
pub gguf_repo: Option<String>,
|
||||
}
|
||||
|
||||
/// Get all recommended models
|
||||
|
|
@ -46,6 +52,7 @@ pub fn get_recommended_models() -> Vec<ModelDefinition> {
|
|||
memory_gb: 9.5,
|
||||
context_length: 32768,
|
||||
notes: "Best overall performance for reasoning tasks on M4 Pro".to_string(),
|
||||
gguf_repo: None,
|
||||
},
|
||||
// Fast instruction following
|
||||
ModelDefinition {
|
||||
|
|
@ -59,6 +66,7 @@ pub fn get_recommended_models() -> Vec<ModelDefinition> {
|
|||
memory_gb: 4.5,
|
||||
context_length: 32768,
|
||||
notes: "Excellent speed/quality tradeoff with sliding window attention".to_string(),
|
||||
gguf_repo: Some("bartowski/Mistral-7B-Instruct-v0.3-GGUF".to_string()),
|
||||
},
|
||||
// Tiny/testing model
|
||||
ModelDefinition {
|
||||
|
|
@ -72,6 +80,7 @@ pub fn get_recommended_models() -> Vec<ModelDefinition> {
|
|||
memory_gb: 2.5,
|
||||
context_length: 16384,
|
||||
notes: "Surprisingly capable for its size, fast inference".to_string(),
|
||||
gguf_repo: Some("bartowski/microsoft_Phi-4-mini-instruct-GGUF".to_string()),
|
||||
},
|
||||
// Tool use model
|
||||
ModelDefinition {
|
||||
|
|
@ -85,6 +94,7 @@ pub fn get_recommended_models() -> Vec<ModelDefinition> {
|
|||
memory_gb: 2.2,
|
||||
context_length: 131072,
|
||||
notes: "Optimized for tool use and function calling".to_string(),
|
||||
gguf_repo: Some("bartowski/Llama-3.2-3B-Instruct-GGUF".to_string()),
|
||||
},
|
||||
// Code-specific model
|
||||
ModelDefinition {
|
||||
|
|
@ -98,6 +108,7 @@ pub fn get_recommended_models() -> Vec<ModelDefinition> {
|
|||
memory_gb: 4.8,
|
||||
context_length: 32768,
|
||||
notes: "Specialized for coding tasks, excellent at code completion".to_string(),
|
||||
gguf_repo: None,
|
||||
},
|
||||
// Large reasoning model (for when you have the memory)
|
||||
ModelDefinition {
|
||||
|
|
@ -111,6 +122,7 @@ pub fn get_recommended_models() -> Vec<ModelDefinition> {
|
|||
memory_gb: 20.0,
|
||||
context_length: 32768,
|
||||
notes: "Requires significant memory, but provides best quality".to_string(),
|
||||
gguf_repo: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -147,6 +159,27 @@ pub fn resolve_model_id(identifier: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve the repo that hosts the *weights* for `identifier` at `quant`.
|
||||
///
|
||||
/// A quantized (GGUF) request against an alias whose `hf_id` is a
|
||||
/// safetensors-only repo is routed to the registry's `gguf_repo` twin when one
|
||||
/// is defined. Repos that already host GGUF files (id contains "GGUF") and
|
||||
/// unquantized requests resolve as before. Unknown identifiers pass through
|
||||
/// unchanged — the download command then validates against the actual HF file
|
||||
/// listing and fails with the available files rather than a 404 on a glob.
|
||||
pub fn resolve_weights_repo(identifier: &str, quant: QuantPreset) -> String {
|
||||
let base = resolve_model_id(identifier);
|
||||
if quant == QuantPreset::None || base.to_ascii_uppercase().contains("GGUF") {
|
||||
return base;
|
||||
}
|
||||
if let Some(model) = get_model(identifier) {
|
||||
if let Some(gguf_repo) = model.gguf_repo {
|
||||
return gguf_repo;
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
/// Get model aliases map
|
||||
pub fn get_aliases() -> HashMap<String, String> {
|
||||
get_recommended_models()
|
||||
|
|
@ -190,6 +223,18 @@ impl QuantPreset {
|
|||
}
|
||||
}
|
||||
|
||||
/// Quant tags to match against actual GGUF filenames (lowercase).
|
||||
/// Repos vary in spelling (e.g. Qwen ships `fp16`, bartowski ships `f16`),
|
||||
/// so each preset may accept several tags.
|
||||
pub fn gguf_tags(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Q4K => &["q4_k_m"],
|
||||
Self::Q8 => &["q8_0"],
|
||||
Self::F16 => &["f16", "fp16"],
|
||||
Self::None => &["f32", "fp32"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get bytes per weight
|
||||
pub fn bytes_per_weight(&self) -> f32 {
|
||||
match self {
|
||||
|
|
@ -241,4 +286,43 @@ mod tests {
|
|||
assert_eq!(QuantPreset::from_str("q4k"), Some(QuantPreset::Q4K));
|
||||
assert_eq!(QuantPreset::Q4K.bytes_per_weight(), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weights_repo_gguf_twin_for_safetensors_alias() {
|
||||
// `phi` -> microsoft/Phi-4-mini-instruct hosts safetensors only; a
|
||||
// quantized request must route to the GGUF twin, not the base repo.
|
||||
let repo = resolve_weights_repo("phi", QuantPreset::Q4K);
|
||||
assert_eq!(repo, "bartowski/microsoft_Phi-4-mini-instruct-GGUF");
|
||||
assert!(repo.contains("GGUF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weights_repo_unquantized_stays_on_base_repo() {
|
||||
assert_eq!(
|
||||
resolve_weights_repo("phi", QuantPreset::None),
|
||||
"microsoft/Phi-4-mini-instruct"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weights_repo_native_gguf_alias_unchanged() {
|
||||
assert_eq!(
|
||||
resolve_weights_repo("qwen", QuantPreset::Q4K),
|
||||
"Qwen/Qwen2.5-14B-Instruct-GGUF"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weights_repo_unknown_id_passes_through() {
|
||||
assert_eq!(
|
||||
resolve_weights_repo("custom/model", QuantPreset::Q4K),
|
||||
"custom/model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gguf_tags_cover_repo_spelling_variants() {
|
||||
assert!(QuantPreset::F16.gguf_tags().contains(&"fp16"));
|
||||
assert_eq!(QuantPreset::Q4K.gguf_tags(), &["q4_k_m"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# ADR-259: ruvllm as Local Mutator Backend for Darwin Mode
|
||||
|
||||
**Status:** Implemented (code + unit tests + CLI; live-serve e2e blocked by a ruvllm download bug — see Implementation status)
|
||||
**Status:** Implemented (code + unit tests + CLI; the download-path bugs that blocked the live-serve e2e are fixed — see Implementation status)
|
||||
|
||||
## Implementation status (2026-06-18)
|
||||
|
||||
|
|
@ -11,11 +11,16 @@ Implemented in `agent-harness-generator` (`@metaharness/darwin`):
|
|||
- `__tests__/ruvllm-mutator.test.ts` — 4 tests vs a real `node:http` mock (success, fence-strip,
|
||||
unreachable→no-op, malformed→no-op). Full darwin suite **354/354** green.
|
||||
|
||||
**Honest gap:** the live-serve e2e (evolve against a real local model) is **blocked by a ruvllm
|
||||
2.1.0 download bug** — `ruvllm download phi` fails on `tokenizer_config.json` (the file is served
|
||||
via an HTTP 307 redirect that ruvllm does not follow; `curl -L` fetches it fine). So the HTTP
|
||||
*contract* is verified by unit tests, but an end-to-end run against a served model is pending a
|
||||
ruvllm fix (or manual model placement). Recommend fixing the redirect-follow in `ruvllm download`.
|
||||
**Honest gap (updated):** the live-serve e2e (evolve against a real local model) was blocked by
|
||||
two `ruvllm download` bugs, both now fixed:
|
||||
1. **HTTP 307 redirect on aux files** (`tokenizer_config.json` etc.) — **fixed 2026-06-18** in
|
||||
commit `946275a61` (PR #590) via a redirect-following `curl -L` fallback.
|
||||
2. **GGUF weight downloads** — the remaining gap after PR #590: `get_files_to_download()` sent an
|
||||
unexpanded glob (`*Q4_K_M.gguf`) literally (404), and the GGUF alias resolved to the
|
||||
safetensors repo. Fixed (PIR WP0b, issue #846) by expanding the pattern against the real
|
||||
HuggingFace file listing (`/api/models/<id>/tree/<rev>`, multi-part GGUF included) and by
|
||||
routing quantized requests to a registry-defined GGUF twin repo.
|
||||
With both fixed, an end-to-end run against a served model is unblocked.
|
||||
|
||||
**Value note (ADR-087):** the mutator is not the quality lever — deterministic and frontier-LLM
|
||||
mutators both hit the 0.985 scorer ceiling. RuvllmMutator's benefit is *operational* (fully local,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue