use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum RepoStatus {
NotFound,
Empty { repo_id: String, files: Vec<String> },
HasModel {
repo_id: String,
files: Vec<String>,
model_size_bytes: u64,
},
}
#[derive(Debug, Clone)]
pub struct BaseModel {
pub repo_id: &'static str,
pub display_name: &'static str,
pub size_display: &'static str,
pub params: &'static str,
pub description: &'static str,
}
#[derive(Debug, Clone)]
pub enum CloneProgress {
CheckingRepo,
RepoChecked(RepoStatus),
CreatingRepo,
RepoReady,
Failed(String),
}
pub const BASE_MODELS: &[BaseModel] = &[
BaseModel {
repo_id: "Qwen/Qwen3-0.6B",
display_name: "Qwen3 0.6B",
size_display: "~1.2 GB",
params: "751M",
description: "Smallest Qwen3 — ideal for mobile & quick experiments",
},
BaseModel {
repo_id: "Qwen/Qwen2.5-1.5B-Instruct",
display_name: "Qwen2.5 1.5B Instruct",
size_display: "~3.0 GB",
params: "1.5B",
description: "Balanced size — good for instruction-following tasks",
},
BaseModel {
repo_id: "Qwen/Qwen3-1.7B",
display_name: "Qwen3 1.7B",
size_display: "~3.4 GB",
params: "1.7B",
description: "Latest Qwen3 small model — strong reasoning",
},
BaseModel {
repo_id: "Qwen/Qwen3-4B",
display_name: "Qwen3 4B",
size_display: "~8.0 GB",
params: "4B",
description: "Larger Qwen3 — best quality, macOS recommended",
},
];
#[derive(Debug, Deserialize)]
struct HfRepoInfo {
#[serde(default)]
siblings: Vec<HfFileSibling>,
}
#[derive(Debug, Deserialize)]
struct HfFileSibling {
rfilename: String,
#[serde(default)]
size: Option<u64>,
}
pub async fn check_repo(repo_id: &str, hf_token: &str) -> Result<RepoStatus> {
let client = build_client()?;
let mut request = client.get(format!("https://huggingface.co/api/models/{repo_id}"));
if !hf_token.is_empty() {
request = request.bearer_auth(hf_token);
}
let response = request
.send()
.await
.context("Failed to reach HuggingFace API")?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(RepoStatus::NotFound);
}
let response = response
.error_for_status()
.context("HuggingFace API error")?;
let info: HfRepoInfo = response
.json()
.await
.context("Failed to parse repo metadata")?;
let all_files: Vec<String> = info.siblings.iter().map(|s| s.rfilename.clone()).collect();
let model_files: Vec<&HfFileSibling> = info
.siblings
.iter()
.filter(|s| is_model_file(&s.rfilename))
.collect();
if model_files.is_empty() {
Ok(RepoStatus::Empty {
repo_id: repo_id.to_string(),
files: all_files,
})
} else {
let total_size: u64 = model_files.iter().filter_map(|s| s.size).sum();
Ok(RepoStatus::HasModel {
repo_id: repo_id.to_string(),
files: all_files,
model_size_bytes: total_size,
})
}
}
pub async fn create_repo(repo_id: &str, hf_token: &str) -> Result<()> {
let client = build_client()?;
let body = if let Some((org, name)) = repo_id.split_once('/') {
serde_json::json!({
"type": "model",
"name": name,
"organization": org,
"private": false,
"sdk": "onde-cli"
})
} else {
serde_json::json!({
"type": "model",
"name": repo_id,
"private": false,
"sdk": "onde-cli"
})
};
let response = client
.post("https://huggingface.co/api/repos/create")
.bearer_auth(hf_token)
.json(&body)
.send()
.await
.context("Failed to reach HuggingFace API")?;
let status = response.status();
if !status.is_success() && status.as_u16() != 409 {
let body_text = response.text().await.unwrap_or_default();
anyhow::bail!("Failed to create repo {repo_id}: {status} {body_text}");
}
Ok(())
}
pub async fn start_check_repo(
repo_id: String,
hf_token: String,
tx: tokio::sync::mpsc::UnboundedSender<CloneProgress>,
) {
let _ = tx.send(CloneProgress::CheckingRepo);
match check_repo(&repo_id, &hf_token).await {
Ok(status) => {
let _ = tx.send(CloneProgress::RepoChecked(status));
}
Err(e) => {
let _ = tx.send(CloneProgress::Failed(format!("{e:#}")));
}
}
}
pub async fn start_create_repo(
repo_id: String,
hf_token: String,
tx: tokio::sync::mpsc::UnboundedSender<CloneProgress>,
) {
let _ = tx.send(CloneProgress::CreatingRepo);
match create_repo(&repo_id, &hf_token).await {
Ok(()) => {
let _ = tx.send(CloneProgress::RepoReady);
}
Err(e) => {
let _ = tx.send(CloneProgress::Failed(format!("{e:#}")));
}
}
}
fn build_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("onde-cli/0.2")
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.context("Failed to build HTTP client")
}
fn is_model_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
lower.ends_with(".safetensors")
|| lower.ends_with(".gguf")
|| lower.ends_with(".bin")
|| lower.ends_with(".pt")
|| lower.ends_with(".pth")
}