|
|
@@ -0,0 +1,195 @@
|
|
|
+//! Hugging Face catalogue helpers: `sm121 hf search|card`.
|
|
|
+//!
|
|
|
+//! Read-only, straight against the public HF API — the point is to pick a
|
|
|
+//! model **and a revision** for a recipe without opening a browser: search by
|
|
|
+//! name, see the card (tags, size, gated/licence, last update) and list the
|
|
|
+//! revisions with their resolved commits, then take that `org/name@revision`
|
|
|
+//! into the recipe / `[flare].revision`.
|
|
|
+
|
|
|
+use anyhow::{Result, bail};
|
|
|
+
|
|
|
+fn client() -> Result<reqwest::Client> {
|
|
|
+ Ok(reqwest::Client::builder()
|
|
|
+ .timeout(std::time::Duration::from_secs(25))
|
|
|
+ .user_agent("sm121")
|
|
|
+ .build()?)
|
|
|
+}
|
|
|
+
|
|
|
+/// Token from the environment, so gated repos resolve.
|
|
|
+fn token() -> Option<String> {
|
|
|
+ std::env::var("HF_TOKEN").ok().filter(|t| !t.is_empty())
|
|
|
+}
|
|
|
+
|
|
|
+async fn get_json(url: &str) -> Result<serde_json::Value> {
|
|
|
+ let mut req = client()?.get(url);
|
|
|
+ if let Some(t) = token() {
|
|
|
+ req = req.bearer_auth(t);
|
|
|
+ }
|
|
|
+ let res = req.send().await?;
|
|
|
+ if !res.status().is_success() {
|
|
|
+ bail!("{url} → HTTP {}", res.status());
|
|
|
+ }
|
|
|
+ Ok(res.json().await?)
|
|
|
+}
|
|
|
+
|
|
|
+/// `sm121 hf search <query>`
|
|
|
+pub async fn search(query: &str, limit: usize, json: bool) -> Result<()> {
|
|
|
+ let url = format!(
|
|
|
+ "https://huggingface.co/api/models?search={}&limit={limit}&sort=downloads&direction=-1&full=true",
|
|
|
+ urlencode(query)
|
|
|
+ );
|
|
|
+ let body = get_json(&url).await?;
|
|
|
+ let items = body.as_array().cloned().unwrap_or_default();
|
|
|
+ if json {
|
|
|
+ println!("{}", serde_json::to_string_pretty(&body)?);
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ if items.is_empty() {
|
|
|
+ println!("no models match '{query}'");
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ println!("{:<52} {:>9} {:>6} updated", "model", "downloads", "likes");
|
|
|
+ for m in &items {
|
|
|
+ let id = m.get("id").and_then(|v| v.as_str()).unwrap_or("?");
|
|
|
+ let dl = m.get("downloads").and_then(|v| v.as_u64()).unwrap_or(0);
|
|
|
+ let likes = m.get("likes").and_then(|v| v.as_u64()).unwrap_or(0);
|
|
|
+ let upd = m
|
|
|
+ .get("lastModified")
|
|
|
+ .and_then(|v| v.as_str())
|
|
|
+ .map(|s| s.get(0..10).unwrap_or(s))
|
|
|
+ .unwrap_or("");
|
|
|
+ println!("{id:<52} {dl:>9} {likes:>6} {upd}");
|
|
|
+ }
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+/// `sm121 hf card <repo> [--revision R]` — the card summary plus every
|
|
|
+/// revision (branch/tag) with its commit, so a recipe can pin one.
|
|
|
+pub async fn card(repo: &str, revision: Option<&str>, json: bool) -> Result<()> {
|
|
|
+ let info = get_json(&format!(
|
|
|
+ "https://huggingface.co/api/models/{repo}?full=true"
|
|
|
+ ))
|
|
|
+ .await?;
|
|
|
+ let refs = get_json(&format!("https://huggingface.co/api/models/{repo}/refs")).await?;
|
|
|
+ let files = match revision {
|
|
|
+ Some(rev) => Some(
|
|
|
+ get_json(&format!(
|
|
|
+ "https://huggingface.co/api/models/{repo}/tree/{rev}?recursive=1"
|
|
|
+ ))
|
|
|
+ .await?,
|
|
|
+ ),
|
|
|
+ None => None,
|
|
|
+ };
|
|
|
+ if json {
|
|
|
+ println!(
|
|
|
+ "{}",
|
|
|
+ serde_json::to_string_pretty(&serde_json::json!({
|
|
|
+ "model": info, "refs": refs, "files": files,
|
|
|
+ }))?
|
|
|
+ );
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ let s = |k: &str| info.get(k).and_then(|v| v.as_str()).unwrap_or("");
|
|
|
+ let n = |k: &str| info.get(k).and_then(|v| v.as_u64());
|
|
|
+ println!(
|
|
|
+ "{}",
|
|
|
+ info.get("id").and_then(|v| v.as_str()).unwrap_or(repo)
|
|
|
+ );
|
|
|
+ if let Some(c) = info.get("cardData") {
|
|
|
+ let lic = c.get("license").and_then(|v| v.as_str()).unwrap_or("—");
|
|
|
+ let base = c
|
|
|
+ .get("base_model")
|
|
|
+ .and_then(|v| {
|
|
|
+ v.as_str().map(str::to_string).or_else(|| {
|
|
|
+ v.as_array().map(|a| {
|
|
|
+ a.iter()
|
|
|
+ .filter_map(|x| x.as_str())
|
|
|
+ .collect::<Vec<_>>()
|
|
|
+ .join(", ")
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+ .unwrap_or_else(|| "—".into());
|
|
|
+ println!(" licence: {lic} base: {base}");
|
|
|
+ }
|
|
|
+ if !s("pipeline_tag").is_empty() {
|
|
|
+ println!(" task: {}", s("pipeline_tag"));
|
|
|
+ }
|
|
|
+ println!(
|
|
|
+ " updated: {} downloads: {}",
|
|
|
+ s("lastModified"),
|
|
|
+ n("downloads").unwrap_or(0)
|
|
|
+ );
|
|
|
+ if info.get("gated").and_then(|v| v.as_bool()) == Some(true) {
|
|
|
+ println!(" ⚠ gated repo — needs HF_TOKEN");
|
|
|
+ }
|
|
|
+ if let Some(tags) = info.get("tags").and_then(|v| v.as_array()) {
|
|
|
+ let list: Vec<&str> = tags.iter().filter_map(|t| t.as_str()).take(12).collect();
|
|
|
+ println!(" tags: {}", list.join(", "));
|
|
|
+ }
|
|
|
+ if let Some(siblings) = info.get("siblings").and_then(|v| v.as_array()) {
|
|
|
+ println!(" files: {} (use --revision to list)", siblings.len());
|
|
|
+ }
|
|
|
+ if let Some(branches) = refs.get("branches").and_then(|v| v.as_array()) {
|
|
|
+ println!("\nrevisions (branches):");
|
|
|
+ for b in branches {
|
|
|
+ println!(
|
|
|
+ " {:<24} {}",
|
|
|
+ b.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
|
|
|
+ b.get("targetCommit")
|
|
|
+ .and_then(|v| v.as_str())
|
|
|
+ .map(|c| c.get(0..12).unwrap_or(c))
|
|
|
+ .unwrap_or("")
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if let Some(tags) = refs.get("tags").and_then(|v| v.as_array()) {
|
|
|
+ let named: Vec<String> = tags
|
|
|
+ .iter()
|
|
|
+ .filter_map(|t| t.get("name").and_then(|v| v.as_str()).map(str::to_string))
|
|
|
+ .collect();
|
|
|
+ if !named.is_empty() {
|
|
|
+ println!("tags: {}", named.join(", "));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if let Some(files) = files.and_then(|f| f.as_array().cloned()) {
|
|
|
+ let mut total = 0u64;
|
|
|
+ println!("\nfiles at {}:", revision.unwrap_or("main"));
|
|
|
+ for f in files.iter().take(40) {
|
|
|
+ let size = f.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
|
|
|
+ total += size;
|
|
|
+ println!(
|
|
|
+ " {:>10.2} GB {}",
|
|
|
+ size as f64 / 1e9,
|
|
|
+ f.get("path").and_then(|v| v.as_str()).unwrap_or("?")
|
|
|
+ );
|
|
|
+ }
|
|
|
+ println!(" total ≈ {:.1} GB", total as f64 / 1e9);
|
|
|
+ }
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+fn urlencode(s: &str) -> String {
|
|
|
+ s.bytes()
|
|
|
+ .map(|b| match b {
|
|
|
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
|
+ (b as char).to_string()
|
|
|
+ }
|
|
|
+ b' ' => "+".to_string(),
|
|
|
+ _ => format!("%{b:02X}"),
|
|
|
+ })
|
|
|
+ .collect()
|
|
|
+}
|
|
|
+
|
|
|
+/// CLI dispatch.
|
|
|
+pub async fn dispatch(cmd: &crate::cli::HfCommands) -> Result<()> {
|
|
|
+ use crate::cli::HfCommands as H;
|
|
|
+ match cmd {
|
|
|
+ H::Search { query, limit, json } => search(query, *limit, *json).await,
|
|
|
+ H::Card {
|
|
|
+ repo,
|
|
|
+ revision,
|
|
|
+ json,
|
|
|
+ } => card(repo, revision.as_deref(), *json).await,
|
|
|
+ }
|
|
|
+}
|