1 //! Model picker types and item construction, shared across platforms.
2 //! The unix-only TUI in `chat.rs` pulls from here.
3
4 use onde::inference::GgufModelConfig;
5
6 use crate::setup::DiscoveredModel;
7
8 pub(crate) use crate::setup::ModelCacheHealth;
9
10 // ── Types ─────────────────────────────────────────────────────────────────────
11
12 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13 pub(crate) enum ModelSource {
14 Onde,
15 HuggingFace,
16 /// not downloaded yet — selecting it triggers a download into the app-group cache.
17 Available,
18 Fallback,
19 /// a siGit Code Cloud tier (runs over the network, not on-device).
20 Cloud,
21 }
22
23 #[derive(Clone)]
24 pub(crate) struct ModelPickerItem {
25 pub(crate) display_name: String,
26 pub(crate) description: String,
27 pub(crate) tool_calling: bool,
28 pub(crate) max_tokens: u64,
29 pub(crate) config: GgufModelConfig,
30 pub(crate) source_label: String,
31
32 pub(crate) source: ModelSource,
33 pub(crate) cache_health: ModelCacheHealth,
34 /// `Some(tier)` for a siGit Code Cloud entry; `None` for an on-device model.
35 pub(crate) cloud_tier: Option<String>,
36 }
37
38 // ── Model ID → GgufModelConfig mapping ────────────────────────────────────────
39
40 /// map a HF model ID to its config constructor, or `None` if we don't support it.
41 pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
42 Some(match model_id {
43 "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
44 "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
45 "bartowski/Qwen_Qwen3-14B-GGUF" => GgufModelConfig::qwen3_14b(),
46 "bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(),
47 "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
48 "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
49 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
50 "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
51 "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_7b(),
52 "TheBloke/deepseek-coder-6.7B-instruct-GGUF" => GgufModelConfig::deepseek_coder_6_7b(),
53 _ => return None,
54 })
55 }
56
57 fn is_tool_calling(model_id: &str) -> bool {
58 matches!(
59 model_id,
60 "bartowski/Qwen_Qwen3-4B-GGUF"
61 | "bartowski/Qwen_Qwen3-8B-GGUF"
62 | "bartowski/Qwen_Qwen3-14B-GGUF"
63 | "bartowski/Qwen_Qwen3-1.7B-GGUF"
64 | "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"
65 )
66 }
67
68 /// tool-calling models get more tokens because `<think>` blocks eat into the budget.
69 fn max_tokens_for(model_id: &str) -> u64 {
70 if is_tool_calling(model_id) { 4096 } else { 512 }
71 }
72
73 // ── Builder ───────────────────────────────────────────────────────────────────
74
75 /// collect every model the picker should show: local cache, remote available, fallback.
76 /// sorted by source (Onde > HF > Available > Fallback), then alphabetically.
77 pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
78 let mut items = Vec::new();
79
80 // ── 1. Locally discovered models ─────────────────────────────────────
81 for discovered in crate::setup::discover_local_models() {
82 if let Some(item) = discovered_model_to_picker_item(discovered) {
83 items.push(item);
84 }
85 }
86
87 // ── 2. Supported models not yet downloaded ───────────────────────────
88 for info in onde::inference::models::SUPPORTED_MODEL_INFO {
89 let already_present = items.iter().any(|item| item.config.model_id == info.id);
90 if already_present {
91 continue;
92 }
93
94 let config = match model_id_to_config(info.id) {
95 Some(config) => config,
96 None => continue,
97 };
98
99 let tool_calling = is_tool_calling(info.id);
100 let max_tokens = max_tokens_for(info.id);
101
102 items.push(ModelPickerItem {
103 display_name: config.display_name.clone(),
104 description: config.approx_memory.clone(),
105 tool_calling,
106 max_tokens,
107 config,
108 source_label: "Onde".to_string(),
109
110 source: ModelSource::Available,
111 cache_health: ModelCacheHealth::NotDownloaded,
112 cloud_tier: None,
113 });
114 }
115
116 // ── 3. Fallback (on-device default when nothing else is present) ─────
117 if items.is_empty() {
118 let config = GgufModelConfig::platform_default();
119 let tool_calling = is_tool_calling(&config.model_id);
120 let max_tokens = max_tokens_for(&config.model_id);
121
122 items.push(ModelPickerItem {
123 display_name: config.display_name.clone(),
124 description: config.approx_memory.clone(),
125 tool_calling,
126 max_tokens,
127 config,
128 source_label: "Platform default".to_string(),
129
130 source: ModelSource::Fallback,
131 cache_health: ModelCacheHealth::Complete,
132 cloud_tier: None,
133 });
134 }
135
136 // ── 4. siGit Code Cloud tiers (always offered; sign-in gated at select) ─
137 for tier in crate::provider::CLOUD_TIERS {
138 let label = crate::provider::cloud_tier_label(tier);
139 // Synthetic config: a `sigit-cloud:<tier>` id never collides with a real
140 // HuggingFace id (no `/`), so on-device matching code stays inert.
141 let config = GgufModelConfig {
142 model_id: format!("sigit-cloud:{tier}"),
143 files: Vec::new(),
144 tok_model_id: None,
145 display_name: label.clone(),
146 approx_memory: "Cloud".to_string(),
147 chat_template: None,
148 };
149 items.push(ModelPickerItem {
150 display_name: label,
151 description: "siGit Code Cloud".to_string(),
152 tool_calling: true,
153 max_tokens: 4096,
154 config,
155 source_label: "siGit Code Cloud".to_string(),
156 source: ModelSource::Cloud,
157 cache_health: ModelCacheHealth::Complete,
158 cloud_tier: Some((*tier).to_string()),
159 });
160 }
161
162 items.sort_by(|left, right| {
163 left.source
164 .cmp(&right.source)
165 .then_with(|| left.display_name.cmp(&right.display_name))
166 });
167
168 items
169 }
170
171 /// Picker items restricted to on-device models (no cloud tiers). Used by the
172 /// model-loading and ACP session-config paths, which only handle local GGUF
173 /// models. The cloud tiers are an interactive TUI-picker feature.
174 pub(crate) fn local_picker_items() -> Vec<ModelPickerItem> {
175 build_model_picker_items()
176 .into_iter()
177 .filter(|item| item.cloud_tier.is_none())
178 .collect()
179 }
180
181 // ── Internal helpers ──────────────────────────────────────────────────────────
182
183 fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
184 let source_label = if model.from_app_group {
185 "Onde".to_string()
186 } else {
187 "HuggingFace".to_string()
188 };
189
190 let config = model_id_to_config(&model.model_id)?;
191
192 let tool_calling = is_tool_calling(&model.model_id);
193 let max_tokens = max_tokens_for(&model.model_id);
194
195 Some(ModelPickerItem {
196 display_name: config.display_name.clone(),
197 description: config.approx_memory.clone(),
198 tool_calling,
199 max_tokens,
200 config,
201 source_label,
202
203 source: if model.from_app_group {
204 ModelSource::Onde
205 } else {
206 ModelSource::HuggingFace
207 },
208 cache_health: model.cache_health,
209 cloud_tier: None,
210 })
211 }