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