@ondeinference / cli / commits / 34726f8

Add HuggingFace upload support and HF_TOKEN env variable

keypair34 committed Apr 22, 2026 at 22:34 UTC 34726f80ee9cd1349a536b488bf48cb9d8818f04
11 files changed +1067 -7
.github/workflows/ci.yml
+4
@@ -56,6 +56,7 @@ jobs:
56 ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
57 GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
58 GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
59 + HF_TOKEN: ${{ secrets.HF_TOKEN }}
60
61 test:
62 name: Test
@@ -80,6 +81,7 @@ jobs:
81 ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
82 GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
83 GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
84 + HF_TOKEN: ${{ secrets.HF_TOKEN }}
85
86 check:
87 name: Cargo check
@@ -104,6 +106,7 @@ jobs:
106 ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
107 GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
108 GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
109 + HF_TOKEN: ${{ secrets.HF_TOKEN }}
110
111 build:
112 name: Build (aarch64-apple-darwin)
@@ -127,6 +130,7 @@ jobs:
130 ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
131 GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
132 GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
133 + HF_TOKEN: ${{ secrets.HF_TOKEN }}
134
135 ci:
136 name: CI status
.github/workflows/release-crates.yml
+1
@@ -19,6 +19,7 @@ env:
19 ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
20 GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
21 GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
22 + HF_TOKEN: ${{ secrets.HF_TOKEN }}
23
24 jobs:
25 verify:
.github/workflows/release-github.yml
+1
@@ -21,6 +21,7 @@ env:
21 ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
22 GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
23 GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
24 + HF_TOKEN: ${{ secrets.HF_TOKEN }}
25
26 jobs:
27 build:
.github/workflows/release-pypi.yml
+2 -1
@@ -20,6 +20,7 @@ env:
20 ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
21 GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
22 GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
23 + HF_TOKEN: ${{ secrets.HF_TOKEN }}
24
25 jobs:
26 build-wheels:
@@ -103,7 +104,7 @@ jobs:
104 working-directory: pypi
105 args: --release --locked --compatibility pypi --out dist
106 before-script-linux: yum install -y perl-core
106 - docker-options: -e ONDE_APP_ID -e ONDE_APP_SECRET -e GRESIQ_API_KEY -e GRESIQ_API_SECRET
107 + docker-options: -e ONDE_APP_ID -e ONDE_APP_SECRET -e GRESIQ_API_KEY -e GRESIQ_API_SECRET -e HF_TOKEN
108
109 - name: Upload wheel artifact
110 uses: actions/upload-artifact@v4
Cargo.lock
+1
@@ -2083,6 +2083,7 @@ dependencies = [
2083 "reqwest",
2084 "serde",
2085 "serde_json",
2086 + "sha2",
2087 "smbcloud-auth-sdk",
2088 "smbcloud-gresiq-sdk",
2089 "smbcloud-model",
Cargo.toml
+1
@@ -39,6 +39,7 @@ tokenizers = { version = "0.20", default-features = false, features = ["onig"] }
39 serde = { version = "1", features = ["derive"] }
40 serde_json = "1"
41 half = "2"
42 +sha2 = "0.10"
43
44 # Metal and Accelerate are macOS-only frameworks; Cargo merges features so on
45 # macOS these are added on top of the base candle-core dep above.
build.rs
+16 -5
@@ -3,24 +3,20 @@ fn main() {
3 // as environment variables, so this is a no-op there.
4 let _ = dotenvy::dotenv();
5
6 + // Required variables — build fails if missing.
7 for var in [
8 "ONDE_APP_ID",
9 "ONDE_APP_SECRET",
10 "GRESIQ_API_KEY",
11 "GRESIQ_API_SECRET",
12 ] {
12 - // Re-run the build script if any of these change.
13 println!("cargo:rerun-if-env-changed={var}");
14
15 match std::env::var(var) {
16 Ok(val) => {
17 - // Forward the value to the compilation unit so env!() resolves.
17 println!("cargo:rustc-env={var}={val}");
18 }
19 Err(_) => {
21 - // Fail the build loudly rather than silently baking in an
22 - // empty string. Add the missing variable to .env (local) or
23 - // to the CI environment (GitHub Actions secrets).
20 eprintln!("error: required build variable `{var}` is not set.");
21 eprintln!(" → Add it to .env for local builds.");
22 eprintln!(" → Add it as a GitHub Actions secret for CI builds.");
@@ -29,6 +25,21 @@ fn main() {
25 }
26 }
27
28 + // Optional variables — baked in when present, empty string when absent.
29 + {
30 + let var = "HF_TOKEN";
31 + println!("cargo:rerun-if-env-changed={var}");
32 + match std::env::var(var) {
33 + Ok(val) => {
34 + println!("cargo:rustc-env={var}={val}");
35 + }
36 + Err(_) => {
37 + // Forward an empty value so env!() still compiles.
38 + println!("cargo:rustc-env={var}=");
39 + }
40 + }
41 + }
42 +
43 // Rebuild whenever .env itself changes.
44 println!("cargo:rerun-if-changed=.env");
45 }
src/app.rs
+149 -1
@@ -21,6 +21,7 @@ pub use smbcloud_gresiq_sdk::{OndeApp, OndeModel};
21 // Set these in .env for local builds; inject as secrets in CI.
22 pub(crate) const ONDE_APP_ID: &str = env!("ONDE_APP_ID");
23 pub(crate) const ONDE_APP_SECRET: &str = env!("ONDE_APP_SECRET");
24 +pub(crate) const HF_TOKEN: &str = env!("HF_TOKEN");
25
26 // GresIQ API credentials for the Onde tenant — distinct from Auth above.
27 pub(crate) const GRESIQ_API_KEY: &str = env!("GRESIQ_API_KEY");
@@ -42,6 +43,7 @@ pub enum Screen {
43 Downloads,
44 ModelDetail,
45 FineTune,
46 + GgufDetail,
47 }
48
49 /// What kind of artifact was found on disk.
@@ -171,6 +173,8 @@ pub enum AuthEvent {
173 // merge + GGUF export
174 MergeProgress(crate::merge::MergeProgress),
175 GgufProgress(crate::gguf::GgufProgress),
176 + // HF upload
177 + UploadProgress(crate::hf_upload::UploadProgress),
178 }
179
180 pub struct App {
@@ -236,6 +240,11 @@ pub struct App {
240 // adapters discovered on the model detail screen
241 pub adapter_list: Vec<AdapterEntry>,
242 pub adapter_cursor: usize,
243 + // GGUF detail + upload
244 + pub selected_gguf: Option<AdapterEntry>,
245 + pub upload_running: bool,
246 + pub upload_progress: Option<crate::hf_upload::UploadProgress>,
247 + pub upload_repo_name: String,
248 }
249
250 impl App {
@@ -290,6 +299,10 @@ impl App {
299 merged_model_dir: None,
300 adapter_list: Vec::new(),
301 adapter_cursor: 0,
302 + selected_gguf: None,
303 + upload_running: false,
304 + upload_progress: None,
305 + upload_repo_name: String::new(),
306 }
307 }
308
@@ -319,7 +332,8 @@ impl App {
332 | AuthEvent::HfSearchResults(_)
333 | AuthEvent::HfSearchFailed(_)
334 | AuthEvent::MergeProgress(_)
322 - | AuthEvent::GgufProgress(_) => {}
335 + | AuthEvent::GgufProgress(_)
336 + | AuthEvent::UploadProgress(_) => {}
337 _ => {
338 self.busy = false;
339 }
@@ -530,6 +544,16 @@ impl App {
544 }
545 self.gguf_progress = Some(progress);
546 }
547 + AuthEvent::UploadProgress(progress) => {
548 + match &progress {
549 + crate::hf_upload::UploadProgress::Done { .. }
550 + | crate::hf_upload::UploadProgress::Failed(_) => {
551 + self.upload_running = false;
552 + }
553 + _ => {}
554 + }
555 + self.upload_progress = Some(progress);
556 + }
557 }
558 }
559 }
@@ -648,6 +672,7 @@ fn handle_key(
672 Screen::Models => handle_key_models(app, key, tx),
673 Screen::Downloads => handle_key_downloads(app, key, tx),
674 Screen::ModelDetail => handle_key_model_detail(app, key, tx),
675 + Screen::GgufDetail => handle_key_gguf_detail(app, key, tx),
676 Screen::FineTune => handle_key_finetune(app, key, tx),
677 }
678 }
@@ -1041,6 +1066,32 @@ fn handle_key_model_detail(
1066 app.adapter_cursor += 1;
1067 }
1068 }
1069 + // Enter on a GGUF artifact — open GGUF detail screen
1070 + (Enter, _)
1071 + if app
1072 + .adapter_list
1073 + .get(app.adapter_cursor)
1074 + .is_some_and(|a| a.kind == ArtifactKind::Gguf) =>
1075 + {
1076 + if let Some(entry) = app.adapter_list.get(app.adapter_cursor) {
1077 + // Derive a default repo name from model + file
1078 + let base_name = app
1079 + .downloads
1080 + .get(app.downloads_cursor)
1081 + .map(|m| m.model_id.replace('/', "-"))
1082 + .unwrap_or_else(|| "finetuned-model".to_string());
1083 + let stem = entry
1084 + .file_name
1085 + .strip_suffix(".gguf")
1086 + .unwrap_or(&entry.file_name);
1087 + app.upload_repo_name = format!("ondeinference/{base_name}-{stem}");
1088 + app.selected_gguf = Some(entry.clone());
1089 + app.upload_progress = None;
1090 + app.upload_running = false;
1091 + app.screen = Screen::GgufDetail;
1092 + app.status = Status::neutral("GGUF model details. u · upload to HuggingFace");
1093 + }
1094 + }
1095 // Enter or 'm' — merge the selected LoRA adapter (skip fine-tuning)
1096 (Enter, _) | (Char('m'), KeyModifiers::NONE)
1097 if app
@@ -1271,6 +1322,103 @@ fn make_artifact_entry(
1322 })
1323 }
1324
1325 +fn handle_key_gguf_detail(
1326 + app: &mut App,
1327 + key: crossterm::event::KeyEvent,
1328 + tx: mpsc::UnboundedSender<AuthEvent>,
1329 +) {
1330 + use KeyCode::*;
1331 +
1332 + // While uploading, only allow Ctrl+C
1333 + if app.upload_running {
1334 + if matches!(
1335 + (key.code, key.modifiers),
1336 + (Char('c'), KeyModifiers::CONTROL)
1337 + ) {
1338 + app.should_quit = true;
1339 + }
1340 + return;
1341 + }
1342 +
1343 + // If upload is done or failed, Esc goes back; otherwise show result
1344 + if let Some(crate::hf_upload::UploadProgress::Done { ref url }) = app.upload_progress {
1345 + let url_owned = url.clone();
1346 + match (key.code, key.modifiers) {
1347 + (Esc, _) => {
1348 + app.screen = Screen::ModelDetail;
1349 + app.selected_gguf = None;
1350 + app.upload_progress = None;
1351 + app.status = Status::success(format!("Uploaded: {url_owned}"));
1352 + }
1353 + (Char('c'), KeyModifiers::CONTROL) => {
1354 + app.should_quit = true;
1355 + }
1356 + _ => {}
1357 + }
1358 + return;
1359 + }
1360 +
1361 + match (key.code, key.modifiers) {
1362 + (Esc, _) => {
1363 + app.screen = Screen::ModelDetail;
1364 + app.selected_gguf = None;
1365 + app.upload_progress = None;
1366 + app.status = Status::neutral("Back to model.");
1367 + }
1368 + (Char('c'), KeyModifiers::CONTROL) => {
1369 + app.should_quit = true;
1370 + }
1371 + // Edit the repo name field
1372 + (Backspace, _) => {
1373 + app.upload_repo_name.pop();
1374 + }
1375 + // Enter — start the upload
1376 + (Enter, _) => {
1377 + if app.upload_repo_name.is_empty() {
1378 + app.status = Status::error("Repo name cannot be empty.");
1379 + return;
1380 + }
1381 + let token = HF_TOKEN.to_string();
1382 + if token.is_empty() {
1383 + app.status = Status::error("No HF_TOKEN set. Add it to .env and rebuild.");
1384 + return;
1385 + }
1386 + let Some(ref gguf) = app.selected_gguf else {
1387 + app.status = Status::error("No GGUF file selected.");
1388 + return;
1389 + };
1390 +
1391 + let config = crate::hf_upload::UploadConfig {
1392 + file_path: gguf.path.clone(),
1393 + repo_id: app.upload_repo_name.clone(),
1394 + path_in_repo: gguf.file_name.clone(),
1395 + hf_token: token,
1396 + commit_message: format!("Upload fine-tuned GGUF: {}", gguf.file_name),
1397 + };
1398 +
1399 + app.upload_running = true;
1400 + app.upload_progress = None;
1401 + app.status = Status::neutral("Uploading to HuggingFace…");
1402 +
1403 + let (upload_tx, mut upload_rx) = mpsc::unbounded_channel();
1404 + let auth_tx = tx.clone();
1405 + tokio::spawn(async move {
1406 + crate::hf_upload::start_upload(config, upload_tx).await;
1407 + });
1408 + tokio::spawn(async move {
1409 + while let Some(progress) = upload_rx.recv().await {
1410 + let _ = auth_tx.send(AuthEvent::UploadProgress(progress));
1411 + }
1412 + });
1413 + }
1414 + // Any printable char edits the repo name
1415 + (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
1416 + app.upload_repo_name.push(c);
1417 + }
1418 + _ => {}
1419 + }
1420 +}
1421 +
1422 fn format_adapter_size(bytes: u64) -> String {
1423 if bytes >= 1_000_000_000 {
1424 format!("{:.1}GB", bytes as f64 / 1e9)
src/hf_upload.rs new
+659
@@ -0,0 +1,659 @@
1 +//! Upload fine-tuned GGUF models to a HuggingFace repository.
2 +//!
3 +//! Uses the HuggingFace Hub LFS upload flow:
4 +//! 1. `POST /api/repos/create` — create the repo (409 = already exists, OK)
5 +//! 2. `POST /api/models/{repo}/preupload/main` — check upload mode
6 +//! 3. Compute SHA-256 of the local file
7 +//! 4. `POST /{repo}.git/info/lfs/objects/batch` — get the S3 upload URL
8 +//! 5. `PUT {s3_url}` — stream the file to S3
9 +//! 6. `POST /{repo}.git/info/lfs/objects/verify` — confirm the upload
10 +//! 7. `POST /api/models/{repo}/commit/main` — create the commit
11 +//!
12 +//! Progress is reported through a `tokio::sync::mpsc::UnboundedSender` so the
13 +//! TUI can drive a progress bar without blocking.
14 +
15 +use std::path::PathBuf;
16 +
17 +use anyhow::{Context, Result};
18 +use sha2::{Digest, Sha256};
19 +
20 +// ---------------------------------------------------------------------------
21 +// Public types
22 +// ---------------------------------------------------------------------------
23 +
24 +/// Everything needed to upload a single file to HuggingFace.
25 +pub struct UploadConfig {
26 + /// Path to the local `.gguf` file.
27 + pub file_path: PathBuf,
28 + /// Full repo identifier, e.g. `ondeinference/qwen2.5-0.5b-finetuned`.
29 + pub repo_id: String,
30 + /// Destination filename inside the repo, e.g. `model-finetuned-q8_0.gguf`.
31 + pub path_in_repo: String,
32 + /// HuggingFace Bearer token.
33 + pub hf_token: String,
34 + /// Commit message shown on HuggingFace.
35 + pub commit_message: String,
36 +}
37 +
38 +/// Progress updates emitted during the upload.
39 +pub enum UploadProgress {
40 + /// About to create (or confirm) the HuggingFace repo.
41 + CreatingRepo,
42 + /// Computing SHA-256 hash of the local file.
43 + Hashing { bytes_done: u64, bytes_total: u64 },
44 + /// Streaming file bytes to the server.
45 + Uploading { bytes_sent: u64, bytes_total: u64 },
46 + /// Creating the commit on HuggingFace.
47 + Committing,
48 + /// Upload finished successfully.
49 + Done { url: String },
50 + /// Something went wrong.
51 + Failed(String),
52 +}
53 +
54 +// ---------------------------------------------------------------------------
55 +// Public entry point
56 +// ---------------------------------------------------------------------------
57 +
58 +/// Spawn-friendly wrapper that never returns an error — failures are sent
59 +/// through `tx` as [`UploadProgress::Failed`] instead.
60 +pub async fn start_upload(
61 + config: UploadConfig,
62 + tx: tokio::sync::mpsc::UnboundedSender<UploadProgress>,
63 +) {
64 + if let Err(error) = run_upload(&config, &tx).await {
65 + eprintln!("[upload] error: {error:#}");
66 + let _ = tx.send(UploadProgress::Failed(format!("{error:#}")));
67 + }
68 +}
69 +
70 +// ---------------------------------------------------------------------------
71 +// Implementation
72 +// ---------------------------------------------------------------------------
73 +
74 +/// Size of each chunk streamed during upload (1 MiB).
75 +const CHUNK_SIZE: usize = 1_048_576;
76 +
77 +/// Size of each chunk read during SHA-256 hashing (4 MiB).
78 +const HASH_CHUNK_SIZE: usize = 4 * 1_048_576;
79 +
80 +async fn run_upload(
81 + config: &UploadConfig,
82 + tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
83 +) -> Result<()> {
84 + let client = reqwest::Client::builder()
85 + .user_agent("onde-cli/0.2")
86 + .redirect(reqwest::redirect::Policy::limited(10))
87 + .timeout(std::time::Duration::from_secs(7200))
88 + .build()
89 + .context("failed to build HTTP client")?;
90 +
91 + let file_size = std::fs::metadata(&config.file_path)
92 + .with_context(|| format!("failed to stat {}", config.file_path.display()))?
93 + .len();
94 +
95 + // -- Step 1: Create the repo (or confirm it already exists) ---------------
96 +
97 + let _ = tx.send(UploadProgress::CreatingRepo);
98 + create_repo(&client, &config.hf_token, &config.repo_id).await?;
99 +
100 + // -- Step 2: Preupload check ----------------------------------------------
101 +
102 + let upload_mode = preupload_check(
103 + &client,
104 + &config.hf_token,
105 + &config.repo_id,
106 + &config.path_in_repo,
107 + file_size,
108 + )
109 + .await?;
110 +
111 + eprintln!("[upload] upload mode: {upload_mode}");
112 +
113 + // -- Step 3: Compute SHA-256 of the file ----------------------------------
114 +
115 + let _ = tx.send(UploadProgress::Hashing {
116 + bytes_done: 0,
117 + bytes_total: file_size,
118 + });
119 +
120 + let sha256 = compute_sha256(&config.file_path, file_size, tx).await?;
121 + eprintln!("[upload] sha256: {sha256}");
122 +
123 + if upload_mode == "lfs" {
124 + // -- Step 4: LFS batch — get the S3 upload URL ------------------------
125 +
126 + let lfs_info = lfs_batch(
127 + &client,
128 + &config.hf_token,
129 + &config.repo_id,
130 + &sha256,
131 + file_size,
132 + )
133 + .await?;
134 +
135 + match lfs_info {
136 + LfsBatchResult::AlreadyExists => {
137 + eprintln!("[upload] LFS object already exists, skipping upload");
138 + }
139 + LfsBatchResult::NeedsUpload {
140 + upload_url,
141 + verify_url,
142 + verify_token,
143 + } => {
144 + // -- Step 5: PUT to S3 ----------------------------------------
145 +
146 + let _ = tx.send(UploadProgress::Uploading {
147 + bytes_sent: 0,
148 + bytes_total: file_size,
149 + });
150 +
151 + upload_to_s3(&client, &upload_url, &config.file_path, file_size, tx).await?;
152 +
153 + // -- Step 6: LFS verify ---------------------------------------
154 +
155 + lfs_verify(&client, &verify_url, &verify_token, &sha256, file_size).await?;
156 + }
157 + }
158 +
159 + // -- Step 7: Commit with LFS pointer ----------------------------------
160 +
161 + let _ = tx.send(UploadProgress::Committing);
162 +
163 + lfs_commit(
164 + &client,
165 + &config.hf_token,
166 + &config.repo_id,
167 + &config.path_in_repo,
168 + &config.commit_message,
169 + &sha256,
170 + file_size,
171 + )
172 + .await?;
173 + } else {
174 + // Regular (non-LFS) commit for small files.
175 + let _ = tx.send(UploadProgress::Uploading {
176 + bytes_sent: 0,
177 + bytes_total: file_size,
178 + });
179 +
180 + regular_commit(
181 + &client,
182 + &config.hf_token,
183 + &config.repo_id,
184 + &config.path_in_repo,
185 + &config.commit_message,
186 + &config.file_path,
187 + file_size,
188 + tx,
189 + )
190 + .await?;
191 + }
192 +
193 + let model_url = format!("https://huggingface.co/{}", config.repo_id);
194 + let _ = tx.send(UploadProgress::Done { url: model_url });
195 +
196 + Ok(())
197 +}
198 +
199 +// ---------------------------------------------------------------------------
200 +// Step 1: Create repo
201 +// ---------------------------------------------------------------------------
202 +
203 +async fn create_repo(client: &reqwest::Client, token: &str, repo_id: &str) -> Result<()> {
204 + // The HF API expects "name" (repo only) and "organization" as separate fields.
205 + let create_body = if let Some((org, name)) = repo_id.split_once('/') {
206 + serde_json::json!({
207 + "type": "model",
208 + "name": name,
209 + "organization": org,
210 + "private": false,
211 + "sdk": "onde-cli"
212 + })
213 + } else {
214 + serde_json::json!({
215 + "type": "model",
216 + "name": repo_id,
217 + "private": false,
218 + "sdk": "onde-cli"
219 + })
220 + };
221 +
222 + let response = client
223 + .post("https://huggingface.co/api/repos/create")
224 + .bearer_auth(token)
225 + .json(&create_body)
226 + .send()
227 + .await
228 + .context("failed to reach HuggingFace API")?;
229 +
230 + let status = response.status();
231 + let body = response.text().await.unwrap_or_default();
232 + eprintln!("[upload] create repo: {status} {body}");
233 +
234 + // 200 = created, 409 = already exists — both are fine.
235 + if !status.is_success() && status.as_u16() != 409 {
236 + anyhow::bail!("failed to create repo {repo_id}: {status} {body}");
237 + }
238 +
239 + Ok(())
240 +}
241 +
242 +// ---------------------------------------------------------------------------
243 +// Step 2: Preupload check
244 +// ---------------------------------------------------------------------------
245 +
246 +async fn preupload_check(
247 + client: &reqwest::Client,
248 + token: &str,
249 + repo_id: &str,
250 + path_in_repo: &str,
251 + file_size: u64,
252 +) -> Result<String> {
253 + let body = serde_json::json!({
254 + "files": [{
255 + "path": path_in_repo,
256 + "size": file_size,
257 + "sample": ""
258 + }]
259 + });
260 +
261 + let url = format!("https://huggingface.co/api/models/{repo_id}/preupload/main");
262 +
263 + let response = client
264 + .post(&url)
265 + .bearer_auth(token)
266 + .json(&body)
267 + .send()
268 + .await
269 + .context("preupload check failed")?;
270 +
271 + let status = response.status();
272 + let text = response.text().await.unwrap_or_default();
273 + eprintln!("[upload] preupload: {status} {text}");
274 +
275 + if !status.is_success() {
276 + // Default to LFS for large files if preupload endpoint fails.
277 + if file_size > 10_000_000 {
278 + return Ok("lfs".to_string());
279 + }
280 + anyhow::bail!("preupload check failed: {status} {text}");
281 + }
282 +
283 + // Parse: {"files":[{"path":"...","uploadMode":"lfs"|"regular",...}],...}
284 + let parsed: serde_json::Value =
285 + serde_json::from_str(&text).context("failed to parse preupload response")?;
286 +
287 + let upload_mode = parsed
288 + .get("files")
289 + .and_then(|f| f.as_array())
290 + .and_then(|arr| arr.first())
291 + .and_then(|entry| entry.get("uploadMode"))
292 + .and_then(|v| v.as_str())
293 + .unwrap_or("lfs")
294 + .to_string();
295 +
296 + Ok(upload_mode)
297 +}
298 +
299 +// ---------------------------------------------------------------------------
300 +// Step 3: Compute SHA-256
301 +// ---------------------------------------------------------------------------
302 +
303 +async fn compute_sha256(
304 + path: &std::path::Path,
305 + file_size: u64,
306 + tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
307 +) -> Result<String> {
308 + use std::io::Read;
309 +
310 + let path = path.to_path_buf();
311 + let tx = tx.clone();
312 +
313 + tokio::task::spawn_blocking(move || {
314 + let mut file = std::fs::File::open(&path)
315 + .with_context(|| format!("failed to open {}", path.display()))?;
316 +
317 + let mut hasher = Sha256::new();
318 + let mut buf = vec![0u8; HASH_CHUNK_SIZE];
319 + let mut done: u64 = 0;
320 +
321 + loop {
322 + let n = file
323 + .read(&mut buf)
324 + .with_context(|| format!("failed to read {}", path.display()))?;
325 + if n == 0 {
326 + break;
327 + }
328 + hasher.update(&buf[..n]);
329 + done += n as u64;
330 +
331 + let _ = tx.send(UploadProgress::Hashing {
332 + bytes_done: done,
333 + bytes_total: file_size,
334 + });
335 + }
336 +
337 + let hash = hasher.finalize();
338 + let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
339 + Ok(hex)
340 + })
341 + .await
342 + .context("SHA-256 task panicked")?
343 +}
344 +
345 +// ---------------------------------------------------------------------------
346 +// Step 4: LFS batch
347 +// ---------------------------------------------------------------------------
348 +
349 +enum LfsBatchResult {
350 + /// The object already exists on the server; no upload needed.
351 + AlreadyExists,
352 + /// The object must be uploaded to the given URL.
353 + NeedsUpload {
354 + upload_url: String,
355 + verify_url: String,
356 + verify_token: String,
357 + },
358 +}
359 +
360 +async fn lfs_batch(
361 + client: &reqwest::Client,
362 + token: &str,
363 + repo_id: &str,
364 + sha256: &str,
365 + file_size: u64,
366 +) -> Result<LfsBatchResult> {
367 + let url = format!("https://huggingface.co/{repo_id}.git/info/lfs/objects/batch");
368 +
369 + let body = serde_json::json!({
370 + "operation": "upload",
371 + "transfers": ["basic"],
372 + "objects": [{
373 + "oid": sha256,
374 + "size": file_size
375 + }],
376 + "hash_algo": "sha256"
377 + });
378 +
379 + let response = client
380 + .post(&url)
381 + .bearer_auth(token)
382 + .header("Accept", "application/vnd.git-lfs+json")
383 + .header("Content-Type", "application/vnd.git-lfs+json")
384 + .json(&body)
385 + .send()
386 + .await
387 + .context("LFS batch request failed")?;
388 +
389 + let status = response.status();
390 + let text = response.text().await.unwrap_or_default();
391 + eprintln!(
392 + "[upload] lfs batch: {status} (response len: {})",
393 + text.len()
394 + );
395 +
396 + if !status.is_success() {
397 + anyhow::bail!("LFS batch failed: {status} {text}");
398 + }
399 +
400 + let parsed: serde_json::Value =
401 + serde_json::from_str(&text).context("failed to parse LFS batch response")?;
402 +
403 + let obj = parsed
404 + .get("objects")
405 + .and_then(|o| o.as_array())
406 + .and_then(|a| a.first())
407 + .context("no objects in LFS batch response")?;
408 +
409 + // If there are no "actions", the object already exists on the server.
410 + let actions = match obj.get("actions") {
411 + Some(a) => a,
412 + None => return Ok(LfsBatchResult::AlreadyExists),
413 + };
414 +
415 + let upload_url = actions
416 + .get("upload")
417 + .and_then(|u| u.get("href"))
418 + .and_then(|v| v.as_str())
419 + .context("no upload href in LFS batch response")?
420 + .to_string();
421 +
422 + let verify_url = actions
423 + .get("verify")
424 + .and_then(|v| v.get("href"))
425 + .and_then(|v| v.as_str())
426 + .unwrap_or("")
427 + .to_string();
428 +
429 + let verify_token = actions
430 + .get("verify")
431 + .and_then(|v| v.get("header"))
432 + .and_then(|h| h.get("Authorization"))
433 + .and_then(|v| v.as_str())
434 + .unwrap_or("")
435 + .to_string();
436 +
437 + Ok(LfsBatchResult::NeedsUpload {
438 + upload_url,
439 + verify_url,
440 + verify_token,
441 + })
442 +}
443 +
444 +// ---------------------------------------------------------------------------
445 +// Step 5: Upload to S3
446 +// ---------------------------------------------------------------------------
447 +
448 +async fn upload_to_s3(
449 + client: &reqwest::Client,
450 + upload_url: &str,
451 + file_path: &std::path::Path,
452 + file_size: u64,
453 + tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
454 +) -> Result<()> {
455 + let file = tokio::fs::File::open(file_path)
456 + .await
457 + .with_context(|| format!("failed to open {}", file_path.display()))?;
458 +
459 + let reader = tokio::io::BufReader::new(file);
460 + let progress_tx = tx.clone();
461 +
462 + let stream = futures::stream::unfold(
463 + (reader, 0u64, file_size, progress_tx),
464 + |(mut reader, mut sent, total, progress)| async move {
465 + use tokio::io::AsyncReadExt;
466 + let mut buf = vec![0u8; CHUNK_SIZE];
467 + match reader.read(&mut buf).await {
468 + Ok(0) => None,
469 + Ok(n) => {
470 + sent += n as u64;
471 + let _ = progress.send(UploadProgress::Uploading {
472 + bytes_sent: sent,
473 + bytes_total: total,
474 + });
475 + buf.truncate(n);
476 + Some((
477 + Ok::<_, std::io::Error>(buf),
478 + (reader, sent, total, progress),
479 + ))
480 + }
481 + Err(error) => Some((Err(error), (reader, sent, total, progress))),
482 + }
483 + },
484 + );
485 +
486 + let body = reqwest::Body::wrap_stream(stream);
487 +
488 + let response = client
489 + .put(upload_url)
490 + .header("Content-Length", file_size)
491 + .header("Content-Type", "application/octet-stream")
492 + .body(body)
493 + .send()
494 + .await
495 + .context("S3 upload request failed")?;
496 +
497 + let status = response.status();
498 + if !status.is_success() {
499 + let resp_body = response.text().await.unwrap_or_default();
500 + anyhow::bail!("S3 upload failed: {status} {resp_body}");
501 + }
502 +
503 + eprintln!("[upload] S3 upload complete: {status}");
504 + Ok(())
505 +}
506 +
507 +// ---------------------------------------------------------------------------
508 +// Step 6: LFS verify
509 +// ---------------------------------------------------------------------------
510 +
511 +async fn lfs_verify(
512 + client: &reqwest::Client,
513 + verify_url: &str,
514 + verify_token: &str,
515 + sha256: &str,
516 + file_size: u64,
517 +) -> Result<()> {
518 + if verify_url.is_empty() {
519 + eprintln!("[upload] no verify URL, skipping");
520 + return Ok(());
521 + }
522 +
523 + let body = serde_json::json!({
524 + "oid": sha256,
525 + "size": file_size
526 + });
527 +
528 + let mut request = client
529 + .post(verify_url)
530 + .header("Accept", "application/vnd.git-lfs+json")
531 + .header("Content-Type", "application/vnd.git-lfs+json")
532 + .json(&body);
533 +
534 + if !verify_token.is_empty() {
535 + request = request.header("Authorization", verify_token);
536 + }
537 +
538 + let response = request.send().await.context("LFS verify request failed")?;
539 +
540 + let status = response.status();
541 + let text = response.text().await.unwrap_or_default();
542 + eprintln!("[upload] lfs verify: {status} {text}");
543 +
544 + if !status.is_success() {
545 + anyhow::bail!("LFS verify failed: {status} {text}");
546 + }
547 +
548 + Ok(())
549 +}
550 +
551 +// ---------------------------------------------------------------------------
552 +// Step 7: LFS commit
553 +// ---------------------------------------------------------------------------
554 +
555 +async fn lfs_commit(
556 + client: &reqwest::Client,
557 + token: &str,
558 + repo_id: &str,
559 + path_in_repo: &str,
560 + commit_message: &str,
561 + sha256: &str,
562 + file_size: u64,
563 +) -> Result<()> {
564 + let url = format!("https://huggingface.co/api/models/{repo_id}/commit/main");
565 +
566 + // Build the LFS pointer content that HuggingFace expects.
567 + let lfs_pointer = format!(
568 + "version https://git-lfs.github.com/spec/v1\noid sha256:{sha256}\nsize {file_size}\n"
569 + );
570 +
571 + let body = serde_json::json!({
572 + "summary": commit_message,
573 + "files": [{
574 + "path": path_in_repo,
575 + "content": lfs_pointer,
576 + "lfs": {
577 + "oid": sha256,
578 + "size": file_size
579 + }
580 + }]
581 + });
582 +
583 + let response = client
584 + .post(&url)
585 + .bearer_auth(token)
586 + .json(&body)
587 + .send()
588 + .await
589 + .context("commit request failed")?;
590 +
591 + let status = response.status();
592 + let text = response.text().await.unwrap_or_default();
593 + eprintln!("[upload] commit: {status} {text}");
594 +
595 + if !status.is_success() {
596 + anyhow::bail!("commit failed: {status} {text}");
597 + }
598 +
599 + Ok(())
600 +}
601 +
602 +// ---------------------------------------------------------------------------
603 +// Regular (non-LFS) commit for small files
604 +// ---------------------------------------------------------------------------
605 +
606 +#[allow(clippy::too_many_arguments)]
607 +async fn regular_commit(
608 + client: &reqwest::Client,
609 + token: &str,
610 + repo_id: &str,
611 + path_in_repo: &str,
612 + commit_message: &str,
613 + file_path: &std::path::Path,
614 + file_size: u64,
615 + tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
616 +) -> Result<()> {
617 + let content = tokio::fs::read(file_path)
618 + .await
619 + .with_context(|| format!("failed to read {}", file_path.display()))?;
620 +
621 + let _ = tx.send(UploadProgress::Uploading {
622 + bytes_sent: file_size,
623 + bytes_total: file_size,
624 + });
625 +
626 + // For small regular files, send content as a UTF-8 string.
627 + // Binary files should always go through the LFS path instead.
628 + let content_str = String::from_utf8_lossy(&content).into_owned();
629 +
630 + let url = format!("https://huggingface.co/api/models/{repo_id}/commit/main");
631 +
632 + let body = serde_json::json!({
633 + "summary": commit_message,
634 + "files": [{
635 + "path": path_in_repo,
636 + "content": content_str
637 + }]
638 + });
639 +
640 + let _ = tx.send(UploadProgress::Committing);
641 +
642 + let response = client
643 + .post(&url)
644 + .bearer_auth(token)
645 + .json(&body)
646 + .send()
647 + .await
648 + .context("commit request failed")?;
649 +
650 + let status = response.status();
651 + let text = response.text().await.unwrap_or_default();
652 + eprintln!("[upload] regular commit: {status} {text}");
653 +
654 + if !status.is_success() {
655 + anyhow::bail!("commit failed: {status} {text}");
656 + }
657 +
658 + Ok(())
659 +}
src/main.rs
+1
@@ -4,6 +4,7 @@ mod gguf;
4 mod gresiq;
5 mod hf;
6 mod hf_search;
7 +mod hf_upload;
8 mod merge;
9 mod token;
10 mod ui;
src/ui.rs
+232
@@ -84,6 +84,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
84 Screen::Models => render_models(frame, app, inner),
85 Screen::Downloads => render_downloads(frame, app, inner),
86 Screen::ModelDetail => render_model_detail(frame, app, inner),
87 + Screen::GgufDetail => render_gguf_detail(frame, app, inner),
88 Screen::FineTune => render_finetune(frame, app, inner),
89 }
90 }
@@ -1171,6 +1172,225 @@ fn render_model_detail_adapters(frame: &mut Frame, app: &App, area: Rect) {
1172 }
1173 }
1174
1175 +fn render_gguf_detail(frame: &mut Frame, app: &App, area: Rect) {
1176 + let Some(ref gguf) = app.selected_gguf else {
1177 + return;
1178 + };
1179 +
1180 + let rows = Layout::vertical([
1181 + Constraint::Length(1), // 0 heading
1182 + Constraint::Length(1), // 1 spacer
1183 + Constraint::Length(1), // 2 status
1184 + Constraint::Length(1), // 3 spacer
1185 + Constraint::Length(1), // 4 File label
1186 + Constraint::Length(1), // 5 File value
1187 + Constraint::Length(1), // 6 spacer
1188 + Constraint::Length(1), // 7 Size label
1189 + Constraint::Length(1), // 8 Size value
1190 + Constraint::Length(1), // 9 spacer
1191 + Constraint::Length(1), // 10 Location label
1192 + Constraint::Length(3), // 11 Location value (bordered, wraps)
1193 + Constraint::Length(1), // 12 spacer
1194 + Constraint::Length(1), // 13 Upload heading
1195 + Constraint::Length(1), // 14 Repo Name label
1196 + Constraint::Length(3), // 15 Repo Name input
1197 + Constraint::Length(1), // 16 spacer
1198 + Constraint::Length(1), // 17 upload status / progress
1199 + Constraint::Length(1), // 18 spacer
1200 + Constraint::Min(0), // 19 rest
1201 + Constraint::Length(1), // 20 hint
1202 + ])
1203 + .split(area);
1204 +
1205 + // Heading
1206 + frame.render_widget(
1207 + Paragraph::new(Line::from(Span::styled(
1208 + format!("GGUF Model — {}", gguf.file_name),
1209 + Style::new().fg(C_INK).bold(),
1210 + ))),
1211 + rows[0],
1212 + );
1213 +
1214 + render_status(frame, app, rows[2]);
1215 +
1216 + // File name
1217 + render_detail_field(frame, "File", &gguf.file_name, rows[4], rows[5]);
1218 +
1219 + // Size + modified
1220 + let size_modified = format!("{} {}", gguf.size, gguf.modified);
1221 + render_detail_field(frame, "Size", &size_modified, rows[7], rows[8]);
1222 +
1223 + // Full path in a bordered box
1224 + frame.render_widget(
1225 + Paragraph::new("Location").style(Style::new().fg(C_MUTED)),
1226 + rows[10],
1227 + );
1228 + let path_block = Block::new()
1229 + .borders(Borders::ALL)
1230 + .border_type(BorderType::Rounded)
1231 + .border_style(Style::new().fg(C_LINE))
1232 + .style(Style::new().bg(C_SURFACE_STRONG));
1233 + let path_inner = path_block.inner(rows[11]);
1234 + frame.render_widget(path_block, rows[11]);
1235 + frame.render_widget(
1236 + Paragraph::new(gguf.path.to_string_lossy().to_string())
1237 + .style(Style::new().fg(C_NEON))
1238 + .wrap(Wrap { trim: true }),
1239 + path_inner,
1240 + );
1241 +
1242 + // Upload section
1243 + frame.render_widget(
1244 + Paragraph::new("Upload to HuggingFace").style(Style::new().fg(C_MUTED)),
1245 + rows[13],
1246 + );
1247 +
1248 + // Repo name input
1249 + frame.render_widget(
1250 + Paragraph::new("Repo Name").style(Style::new().fg(C_MUTED)),
1251 + rows[14],
1252 + );
1253 +
1254 + let input_focused = !app.upload_running
1255 + && !matches!(
1256 + app.upload_progress,
1257 + Some(crate::hf_upload::UploadProgress::Done { .. })
1258 + );
1259 + let border_style = if input_focused {
1260 + Style::new().fg(C_NEON)
1261 + } else {
1262 + Style::new().fg(C_LINE)
1263 + };
1264 + let input_block = Block::new()
1265 + .borders(Borders::ALL)
1266 + .border_type(BorderType::Rounded)
1267 + .border_style(border_style)
1268 + .style(Style::new().bg(C_SURFACE_STRONG));
1269 + let input_inner = input_block.inner(rows[15]);
1270 + frame.render_widget(input_block, rows[15]);
1271 + frame.render_widget(
1272 + Paragraph::new(app.upload_repo_name.as_str()).style(Style::new().fg(C_TEXT)),
1273 + input_inner,
1274 + );
1275 + if input_focused {
1276 + let cursor_x = (input_inner.x + app.upload_repo_name.chars().count() as u16)
1277 + .min(input_inner.x + input_inner.width.saturating_sub(1));
1278 + frame.set_cursor_position((cursor_x, input_inner.y));
1279 + }
1280 +
1281 + // Upload progress / status
1282 + render_upload_status(frame, app, rows[17]);
1283 +
1284 + // Hint
1285 + let hint = if app.upload_running {
1286 + "Ctrl+C · quit"
1287 + } else if matches!(
1288 + app.upload_progress,
1289 + Some(crate::hf_upload::UploadProgress::Done { .. })
1290 + ) {
1291 + "Esc · back"
1292 + } else {
1293 + "Enter · upload Esc · back"
1294 + };
1295 + frame.render_widget(
1296 + Paragraph::new(hint).style(Style::new().fg(C_MUTED)),
1297 + rows[20],
1298 + );
1299 +}
1300 +
1301 +fn render_upload_status(frame: &mut Frame, app: &App, area: Rect) {
1302 + match &app.upload_progress {
1303 + Some(crate::hf_upload::UploadProgress::CreatingRepo) => {
1304 + frame.render_widget(
1305 + Paragraph::new("⠿ Creating repository…").style(Style::new().fg(C_MUTED)),
1306 + area,
1307 + );
1308 + }
1309 + Some(crate::hf_upload::UploadProgress::Hashing {
1310 + bytes_done,
1311 + bytes_total,
1312 + }) => {
1313 + let pct = if *bytes_total > 0 {
1314 + (*bytes_done as f64 / *bytes_total as f64 * 100.0) as u64
1315 + } else {
1316 + 0
1317 + };
1318 + frame.render_widget(
1319 + Paragraph::new(format!("⠿ Hashing… {}%", pct)).style(Style::new().fg(C_MUTED)),
1320 + area,
1321 + );
1322 + }
1323 + Some(crate::hf_upload::UploadProgress::Committing) => {
1324 + frame.render_widget(
1325 + Paragraph::new("⠿ Creating commit…").style(Style::new().fg(C_MUTED)),
1326 + area,
1327 + );
1328 + }
1329 + Some(crate::hf_upload::UploadProgress::Uploading {
1330 + bytes_sent,
1331 + bytes_total,
1332 + }) => {
1333 + let pct = if *bytes_total > 0 {
1334 + (*bytes_sent as f64 / *bytes_total as f64 * 100.0) as u64
1335 + } else {
1336 + 0
1337 + };
1338 + let sent_str = fmt_bytes(*bytes_sent as i64);
1339 + let total_str = fmt_bytes(*bytes_total as i64);
1340 + let bar_width = area.width.saturating_sub(30) as usize;
1341 + let filled = if *bytes_total > 0 {
1342 + (*bytes_sent as f64 / *bytes_total as f64 * bar_width as f64) as usize
1343 + } else {
1344 + 0
1345 + };
1346 + let empty = bar_width.saturating_sub(filled);
1347 + let bar = format!(
1348 + "{}{} {}/{} {}%",
1349 + "█".repeat(filled),
1350 + "░".repeat(empty),
1351 + sent_str,
1352 + total_str,
1353 + pct
1354 + );
1355 + frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), area);
1356 + }
1357 + Some(crate::hf_upload::UploadProgress::Done { url }) => {
1358 + frame.render_widget(
1359 + Paragraph::new(Line::from(vec![
1360 + Span::styled("✓ ", Style::new().fg(C_NEON)),
1361 + Span::styled("Uploaded → ", Style::new().fg(C_NEON).bold()),
1362 + Span::styled(url.as_str(), Style::new().fg(C_TEXT)),
1363 + ])),
1364 + area,
1365 + );
1366 + }
1367 + Some(crate::hf_upload::UploadProgress::Failed(msg)) => {
1368 + frame.render_widget(
1369 + Paragraph::new(Line::from(vec![
1370 + Span::styled("✗ Upload failed: ", Style::new().fg(C_DANGER).bold()),
1371 + Span::styled(msg.as_str(), Style::new().fg(C_DANGER)),
1372 + ])),
1373 + area,
1374 + );
1375 + }
1376 + None => {
1377 + if crate::app::HF_TOKEN.is_empty() {
1378 + frame.render_widget(
1379 + Paragraph::new("⚠ No HF_TOKEN in .env — upload disabled")
1380 + .style(Style::new().fg(C_DANGER)),
1381 + area,
1382 + );
1383 + } else {
1384 + frame.render_widget(
1385 + Paragraph::new("Ready to upload. Press Enter to start.")
1386 + .style(Style::new().fg(C_MUTED)),
1387 + area,
1388 + );
1389 + }
1390 + }
1391 + }
1392 +}
1393 +
1394 fn fmt_bytes(bytes: i64) -> String {
1395 if bytes >= 1_000_000_000 {
1396 format!("{:.1}GB", bytes as f64 / 1e9)
@@ -1897,6 +2117,18 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2117 keys.push(Span::styled(" · quit", Style::new().fg(C_MUTED)));
2118 keys
2119 }
2120 + Screen::GgufDetail if app.upload_running => vec![
2121 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2122 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2123 + ],
2124 + Screen::GgufDetail => vec![
2125 + Span::styled("Enter", Style::new().fg(C_NEON)),
2126 + Span::styled(" · upload ", Style::new().fg(C_MUTED)),
2127 + Span::styled("Esc", Style::new().fg(C_NEON)),
2128 + Span::styled(" · back ", Style::new().fg(C_MUTED)),
2129 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2130 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2131 + ],
2132 };
2133
2134 frame.render_widget(