@ondeinference / cli / commits / 41b6b44

Add publish-to-catalog flow after HF upload

After a fine-tuned GGUF finishes uploading to Hugging Face, let the user register it into the GresIQ model catalog and assign it straight to a running app from the same screen, so a fine-tune can go live without an App Store update. Depends on smbcloud-gresiq-sdk 0.4.9's new create_model() (not yet published to crates.io) and the matching backend endpoint in smbcloud-api (not yet deployed) -- this branch will not build from crates.io until 0.4.9 is published. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

keypair34 committed Jul 11, 2026 at 17:59 UTC 41b6b440c44aa2d8d0840c663265196df44f4676
5 files changed +294 -29
Cargo.lock
+3 -25
@@ -3351,7 +3351,7 @@ dependencies = [
3351 "reqwest 0.12.28",
3352 "serde",
3353 "serde_json",
3354 - "smbcloud-gresiq-sdk 0.4.8",
3354 + "smbcloud-gresiq-sdk",
3355 "thiserror 2.0.18",
3356 "tokio",
3357 "tsync",
@@ -3379,7 +3379,7 @@ dependencies = [
3379 "serde_json",
3380 "sha2",
3381 "smbcloud-auth-sdk",
3382 - "smbcloud-gresiq-sdk 0.3.41",
3382 + "smbcloud-gresiq-sdk",
3383 "smbcloud-model 0.3.41",
3384 "smbcloud-network 0.3.41",
3385 "tokenizers 0.20.4",
@@ -5143,25 +5143,7 @@ dependencies = [
5143
5144 [[package]]
5145 name = "smbcloud-gresiq-sdk"
5146 -version = "0.3.41"
5147 -source = "registry+https://github.com/rust-lang/crates.io-index"
5148 -checksum = "02a8c916737a712020c57191bcab728303a3963f3fd1c03bd3ddeaf67c4c0a64"
5149 -dependencies = [
5150 - "anyhow",
5151 - "log",
5152 - "reqwest 0.12.28",
5153 - "serde",
5154 - "serde_json",
5155 - "smbcloud-network 0.3.41",
5156 - "thiserror 2.0.18",
5157 - "tokio",
5158 -]
5159 -
5160 -[[package]]
5161 -name = "smbcloud-gresiq-sdk"
5162 -version = "0.4.8"
5163 -source = "registry+https://github.com/rust-lang/crates.io-index"
5164 -checksum = "27abf6a2b9d9db0246b3d68e4ce447b5569f43ede7ca4ceb08afeed4232f3c02"
5146 +version = "0.4.9"
5147 dependencies = [
5148 "anyhow",
5149 "log",
@@ -5194,8 +5176,6 @@ dependencies = [
5176 [[package]]
5177 name = "smbcloud-model"
5178 version = "0.4.8"
5197 -source = "registry+https://github.com/rust-lang/crates.io-index"
5198 -checksum = "21047dc7f858988cf73f0f70b0e4c087963464c4ba0f4d8c45fbcea4c6d9390a"
5179 dependencies = [
5180 "chrono",
5181 "log",
@@ -5227,8 +5207,6 @@ dependencies = [
5207 [[package]]
5208 name = "smbcloud-network"
5209 version = "0.4.8"
5230 -source = "registry+https://github.com/rust-lang/crates.io-index"
5231 -checksum = "5be96c7f572f0c91032c0a47818201f30481cdcf1965d86e5269a2cb8742ad2b"
5210 dependencies = [
5211 "clap 4.6.1",
5212 "log",
Cargo.toml
+1 -1
@@ -32,7 +32,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
32 smbcloud-auth-sdk = "0.3.34"
33 smbcloud-model = "0.3.35"
34 smbcloud-network = "0.3.35"
35 -smbcloud-gresiq-sdk = "0.3.35"
35 +smbcloud-gresiq-sdk = "0.4.9"
36 candle-core = "0.10.2"
37 candle-nn = "0.10.2"
38 tokenizers = { version = "0.20", default-features = false, features = ["onig"] }
src/app.rs
+193
@@ -47,6 +47,8 @@ pub enum Screen {
47 GgufDetail,
48 CloneRepo,
49 Chat,
50 + /// Pick which app to assign a just-registered fine-tuned model to.
51 + PublishModel,
52 }
53
54 /// An entry in the Onde inference model picker.
@@ -247,6 +249,9 @@ pub enum AuthEvent {
249 model_id: String,
250 },
251 ModelAssignFailed(String),
252 + // publish: register a freshly HF-uploaded fine-tune into the catalog
253 + ModelRegistered(OndeModel),
254 + ModelRegisterFailed(String),
255 // downloads (catalog merged with local HF cache)
256 DownloadsLoaded(Vec<crate::hf::MergedModel>),
257 #[allow(dead_code)] // reserved for future explicit error reporting
@@ -597,6 +602,26 @@ impl App {
602 self.busy = false;
603 self.status = Status::error(msg);
604 }
605 + // publish
606 + AuthEvent::ModelRegistered(model) => {
607 + self.busy = false;
608 + let model_name = model
609 + .name
610 + .clone()
611 + .or_else(|| model.hf_repo_id.clone())
612 + .unwrap_or_else(|| "Model".to_string());
613 + self.models.push(model);
614 + self.models_cursor = self.models.len() - 1;
615 + self.screen = Screen::PublishModel;
616 + self.apps_cursor = 0;
617 + self.apps_offset = 0;
618 + self.status =
619 + Status::success(format!("{model_name} registered. Pick an app to assign it to."));
620 + }
621 + AuthEvent::ModelRegisterFailed(msg) => {
622 + self.busy = false;
623 + self.status = Status::error(format!("Registration failed: {msg}"));
624 + }
625 // downloads
626 AuthEvent::DownloadsLoaded(models) => {
627 self.busy = false;
@@ -927,6 +952,7 @@ fn handle_key(
952 Screen::FineTune => handle_key_finetune(app, key, tx),
953 Screen::CloneRepo => handle_key_clone_repo(app, key, tx),
954 Screen::Chat => handle_key_chat(app, key, tx),
955 + Screen::PublishModel => handle_key_publish_model(app, key, tx),
956 }
957 }
958
@@ -1629,6 +1655,9 @@ fn handle_key_gguf_detail(
1655 app.upload_progress = None;
1656 app.status = Status::success(format!("Uploaded: {url_owned}"));
1657 }
1658 + (Char('a'), KeyModifiers::NONE) => {
1659 + trigger_publish_model(app, tx);
1660 + }
1661 (Char('c'), KeyModifiers::CONTROL) => {
1662 app.should_quit = true;
1663 }
@@ -1718,6 +1747,131 @@ fn handle_key_gguf_detail(
1747 }
1748 }
1749
1750 +/// Registers the just-uploaded GGUF into the GresIQ catalog, private to this
1751 +/// account, then moves to the app picker so it can be assigned right away.
1752 +fn trigger_publish_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1753 + if app.busy {
1754 + return;
1755 + }
1756 + let Some(ref gguf) = app.selected_gguf else {
1757 + return;
1758 + };
1759 + let hf_repo_id = app.upload_repo_name.clone();
1760 + if hf_repo_id.is_empty() {
1761 + app.status = Status::error("No uploaded repo to register.");
1762 + return;
1763 + }
1764 + let gguf_file = gguf.file_name.clone();
1765 + let approx_size_bytes = std::fs::metadata(&gguf.path).ok().map(|m| m.len() as i64);
1766 + let base_model_id = app.current_project.as_ref().map(|p| p.base_model_id.clone());
1767 + let (base_name, family, parameter_class) = derive_base_model_meta(base_model_id.as_deref());
1768 + let display_name = format!("{base_name} (Fine-tuned)");
1769 + let token = token::load().unwrap_or_default();
1770 +
1771 + // Make sure the apps list is ready by the time the picker shows up.
1772 + if !app.apps_loaded {
1773 + trigger_load_apps(app, tx.clone());
1774 + }
1775 +
1776 + app.busy = true;
1777 + app.status = Status::neutral("Registering model…");
1778 +
1779 + tokio::spawn(async move {
1780 + match crate::gresiq::create_model(
1781 + &token,
1782 + &hf_repo_id,
1783 + &display_name,
1784 + &family,
1785 + &parameter_class,
1786 + Some(&gguf_file),
1787 + approx_size_bytes,
1788 + )
1789 + .await
1790 + {
1791 + Ok(model) => {
1792 + let _ = tx.send(AuthEvent::ModelRegistered(model));
1793 + }
1794 + Err(e) => {
1795 + let _ = tx.send(AuthEvent::ModelRegisterFailed(e.to_string()));
1796 + }
1797 + }
1798 + });
1799 +}
1800 +
1801 +/// Best-effort display name / family / parameter class for a fine-tune,
1802 +/// derived from the base model it was trained from. Falls back to generic
1803 +/// values when the base model isn't one of the officially supported ones.
1804 +fn derive_base_model_meta(base_model_id: Option<&str>) -> (String, String, String) {
1805 + let base_model_id = base_model_id.unwrap_or("");
1806 + let info = onde::inference::models::SUPPORTED_MODEL_INFO
1807 + .iter()
1808 + .find(|i| i.id == base_model_id);
1809 +
1810 + let base_name = info.map(|i| i.name.to_string()).unwrap_or_else(|| {
1811 + base_model_id
1812 + .rsplit('/')
1813 + .next()
1814 + .filter(|s| !s.is_empty())
1815 + .unwrap_or("Custom model")
1816 + .to_string()
1817 + });
1818 +
1819 + let haystack = format!("{base_model_id} {base_name}").to_lowercase();
1820 + let family = if haystack.contains("qwen3") {
1821 + "qwen3"
1822 + } else if haystack.contains("qwen2.5-coder") || haystack.contains("qwen2_5-coder") {
1823 + "qwen2.5-coder"
1824 + } else if haystack.contains("qwen2.5") || haystack.contains("qwen2_5") {
1825 + "qwen2.5"
1826 + } else {
1827 + "custom"
1828 + }
1829 + .to_string();
1830 +
1831 + const SIZES: &[&str] = &["0.5B", "1.5B", "3B", "4B", "7B", "14B", "32B", "72B"];
1832 + let haystack_upper = haystack.to_uppercase();
1833 + let parameter_class = SIZES
1834 + .iter()
1835 + .find(|s| haystack_upper.contains(*s))
1836 + .map(|s| s.to_string())
1837 + .unwrap_or_else(|| "unknown".to_string());
1838 +
1839 + (base_name, family, parameter_class)
1840 +}
1841 +
1842 +fn handle_key_publish_model(
1843 + app: &mut App,
1844 + key: crossterm::event::KeyEvent,
1845 + tx: mpsc::UnboundedSender<AuthEvent>,
1846 +) {
1847 + use KeyCode::*;
1848 +
1849 + match (key.code, key.modifiers) {
1850 + (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1851 + app.apps_cursor = app.apps_cursor.saturating_sub(1);
1852 + clamp_apps_scroll(app, MAX_VISIBLE);
1853 + }
1854 + (Down, _) | (Char('j'), KeyModifiers::NONE) => {
1855 + if app.apps_cursor + 1 < app.apps.len() {
1856 + app.apps_cursor += 1;
1857 + }
1858 + clamp_apps_scroll(app, MAX_VISIBLE);
1859 + }
1860 + (Enter, _) if !app.apps.is_empty() => {
1861 + app.assigning_for_app_index = Some(app.apps_cursor);
1862 + submit_assign_model(app, tx);
1863 + }
1864 + (Esc, _) => {
1865 + app.screen = Screen::GgufDetail;
1866 + app.status = Status::neutral("Model registered — assign it later from Apps.");
1867 + }
1868 + (Char('c'), KeyModifiers::CONTROL) => {
1869 + app.should_quit = true;
1870 + }
1871 + _ => {}
1872 + }
1873 +}
1874 +
1875 fn format_adapter_size(bytes: u64) -> String {
1876 if bytes >= 1_000_000_000 {
1877 format!("{:.1}GB", bytes as f64 / 1e9)
@@ -2839,3 +2993,42 @@ fn trigger_inference_download(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent
2993 }
2994 });
2995 }
2996 +
2997 +#[cfg(test)]
2998 +mod tests {
2999 + use super::*;
3000 +
3001 + #[test]
3002 + fn test_derive_base_model_meta_known_qwen3() {
3003 + let (name, family, parameter_class) =
3004 + derive_base_model_meta(Some("bartowski/Qwen_Qwen3-4B-GGUF"));
3005 + assert_eq!(family, "qwen3");
3006 + assert_eq!(parameter_class, "4B");
3007 + assert!(!name.is_empty());
3008 + }
3009 +
3010 + #[test]
3011 + fn test_derive_base_model_meta_known_qwen25_coder() {
3012 + let (_, family, parameter_class) = derive_base_model_meta(Some(
3013 + "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF",
3014 + ));
3015 + assert_eq!(family, "qwen2.5-coder");
3016 + assert_eq!(parameter_class, "1.5B");
3017 + }
3018 +
3019 + #[test]
3020 + fn test_derive_base_model_meta_unknown_falls_back() {
3021 + let (name, family, parameter_class) = derive_base_model_meta(Some("someone/mystery-model"));
3022 + assert_eq!(family, "custom");
3023 + assert_eq!(parameter_class, "unknown");
3024 + assert_eq!(name, "mystery-model");
3025 + }
3026 +
3027 + #[test]
3028 + fn test_derive_base_model_meta_none() {
3029 + let (name, family, parameter_class) = derive_base_model_meta(None);
3030 + assert_eq!(family, "custom");
3031 + assert_eq!(parameter_class, "unknown");
3032 + assert_eq!(name, "Custom model");
3033 + }
3034 +}
src/gresiq.rs
+35 -1
@@ -1,6 +1,6 @@
1 //! Onde credentials baked in. Wraps `smbcloud_gresiq_sdk` so callers only
2 //! need to pass the user's access token.
3 -use smbcloud_gresiq_sdk::{Environment, OndeApp, OndeModel};
3 +use smbcloud_gresiq_sdk::{CreateModelParams, Environment, OndeApp, OndeModel};
4
5 fn api_key() -> &'static str {
6 crate::app::GRESIQ_API_KEY
@@ -34,6 +34,40 @@ pub async fn load_models(token: &str) -> anyhow::Result<Vec<OndeModel>> {
34 .map_err(|e| anyhow::anyhow!("{e}"))
35 }
36
37 +/// Registers a fine-tuned model (just uploaded to Hugging Face) into the
38 +/// catalog, private to this account, so it can then be assigned to an app.
39 +#[allow(clippy::too_many_arguments)]
40 +pub async fn create_model(
41 + token: &str,
42 + hf_repo_id: &str,
43 + name: &str,
44 + family: &str,
45 + parameter_class: &str,
46 + gguf_file: Option<&str>,
47 + approx_size_bytes: Option<i64>,
48 +) -> anyhow::Result<OndeModel> {
49 + smbcloud_gresiq_sdk::create_model(
50 + &Environment::Production,
51 + api_key(),
52 + api_secret(),
53 + token,
54 + CreateModelParams {
55 + hf_repo_id,
56 + name,
57 + org: "Custom",
58 + family,
59 + parameter_class,
60 + format: "gguf",
61 + gguf_file,
62 + modality: Some("text"),
63 + description: Some("Fine-tuned with onde-cli."),
64 + approx_size_bytes,
65 + },
66 + )
67 + .await
68 + .map_err(|e| anyhow::anyhow!("{e}"))
69 +}
70 +
71 pub async fn assign_model(token: &str, onde_app_id: &str, model_id: &str) -> anyhow::Result<()> {
72 smbcloud_gresiq_sdk::assign_model(
73 &Environment::Production,
src/ui.rs
+62 -2
@@ -89,6 +89,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
89 Screen::FineTune => render_finetune(frame, app, inner),
90 Screen::CloneRepo => render_clone_repo(frame, app, inner),
91 Screen::Chat => render_chat(frame, app, inner),
92 + Screen::PublishModel => render_publish_model(frame, app, inner),
93 }
94 }
95
@@ -461,6 +462,47 @@ fn resolve_model_name<'a>(app: &'a App, onde_app: &'a OndeApp) -> &'a str {
462 "No model assigned yet"
463 }
464
465 +// publish (assign a just-registered model) screen
466 +
467 +fn render_publish_model(frame: &mut Frame, app: &App, area: Rect) {
468 + let model_name = app
469 + .models
470 + .get(app.models_cursor)
471 + .and_then(|m| m.name.clone())
472 + .unwrap_or_else(|| "model".to_string());
473 + let heading = format!("Assign {model_name} to app");
474 +
475 + let rows = Layout::vertical([
476 + Constraint::Length(1), // heading
477 + Constraint::Length(1), // spacer
478 + Constraint::Length(1), // column header
479 + Constraint::Length(1), // divider
480 + Constraint::Min(0), // apps list
481 + Constraint::Length(1), // spacer
482 + Constraint::Length(1), // status
483 + ])
484 + .split(area);
485 +
486 + frame.render_widget(
487 + Paragraph::new(heading).style(Style::new().fg(C_INK).bold()),
488 + rows[0],
489 + );
490 +
491 + frame.render_widget(
492 + Paragraph::new(" Name Status Model").style(Style::new().fg(C_MUTED)),
493 + rows[2],
494 + );
495 +
496 + let divider = "─".repeat(area.width as usize);
497 + frame.render_widget(
498 + Paragraph::new(divider).style(Style::new().fg(C_LINE)),
499 + rows[3],
500 + );
501 +
502 + render_apps_list(frame, app, rows[4]);
503 + render_status(frame, app, rows[6]);
504 +}
505 +
506 // app detail screen
507
508 fn render_app_detail(frame: &mut Frame, app: &App, area: Rect) {
@@ -1428,7 +1470,7 @@ fn render_gguf_detail(frame: &mut Frame, app: &App, area: Rect) {
1470 app.upload_progress,
1471 Some(crate::hf_upload::UploadProgress::Done { .. })
1472 ) {
1431 - "Esc · back"
1473 + "a · assign to app Esc · back"
1474 } else if uploadable {
1475 "Enter · upload c · chat Esc · back"
1476 } else {
@@ -2608,11 +2650,19 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2650 .as_ref()
2651 .map(|g| g.is_uploadable())
2652 .unwrap_or(false);
2653 + let uploaded = matches!(
2654 + app.upload_progress,
2655 + Some(crate::hf_upload::UploadProgress::Done { .. })
2656 + );
2657 let mut keys = Vec::new();
2612 - if uploadable {
2658 + if uploadable && !uploaded {
2659 keys.push(Span::styled("Enter", Style::new().fg(C_NEON)));
2660 keys.push(Span::styled(" · upload ", Style::new().fg(C_MUTED)));
2661 }
2662 + if uploaded {
2663 + keys.push(Span::styled("a", Style::new().fg(C_NEON)));
2664 + keys.push(Span::styled(" · assign to app ", Style::new().fg(C_MUTED)));
2665 + }
2666 keys.push(Span::styled("c", Style::new().fg(C_NEON)));
2667 keys.push(Span::styled(" · chat ", Style::new().fg(C_MUTED)));
2668 keys.push(Span::styled("Esc", Style::new().fg(C_NEON)));
@@ -2664,6 +2714,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2714 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2715 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2716 ],
2717 + Screen::PublishModel => vec![
2718 + Span::styled("↑↓", Style::new().fg(C_NEON)),
2719 + Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2720 + Span::styled("Enter", Style::new().fg(C_NEON)),
2721 + Span::styled(" · assign ", Style::new().fg(C_MUTED)),
2722 + Span::styled("Esc", Style::new().fg(C_NEON)),
2723 + Span::styled(" · skip ", Style::new().fg(C_MUTED)),
2724 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2725 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2726 + ],
2727 };
2728
2729 frame.render_widget(