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
+ ¶meter_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(