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) context_window_tokens: u64,
57 pub(crate) config: GgufModelConfig,
58 pub(crate) source_label: String,
59
60 pub(crate) source: ModelSource,
61 pub(crate) cache_health: ModelCacheHealth,
62 /// `Some(tier)` for a siGit Code Cloud entry; `None` for an on-device model.
63 pub(crate) cloud_tier: Option<String>,
64 }
65
66 // ── Model ID → GgufModelConfig mapping ────────────────────────────────────────
67
68 /// map a HF model ID to its config constructor, or `None` if we don't support it.
69 pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
70 Some(match model_id {
71 "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
72 "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
73 "bartowski/Qwen_Qwen3-14B-GGUF" => GgufModelConfig::qwen3_14b(),
74 "bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(),
75 "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
76 "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
77 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
78 "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
79 "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_7b(),
80 "TheBloke/deepseek-coder-6.7B-instruct-GGUF" => GgufModelConfig::deepseek_coder_6_7b(),
81 _ => return None,
82 })
83 }
84
85 fn is_tool_calling(model_id: &str) -> bool {
86 matches!(
87 model_id,
88 "bartowski/Qwen_Qwen3-4B-GGUF"
89 | "bartowski/Qwen_Qwen3-8B-GGUF"
90 | "bartowski/Qwen_Qwen3-14B-GGUF"
91 | "bartowski/Qwen_Qwen3-1.7B-GGUF"
92 | "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"
93 )
94 }
95
96 /// tool-calling models get more tokens because `<think>` blocks eat into the budget.
97 fn max_tokens_for(model_id: &str) -> u64 {
98 if is_tool_calling(model_id) { 4096 } else { 512 }
99 }
100
101 /// Usable context window shown in editor model pickers.
102 ///
103 /// This is intentionally separate from `max_tokens`, which is the generation
104 /// cap. Qwen GGUFs are used at their default 32K window; Qwen2.5 can be stretched
105 /// further with YaRN in some deployments, but siGit Code does not configure that
106 /// here.
107 fn context_window_tokens_for(model_id: &str) -> u64 {
108 if model_id == "TheBloke/deepseek-coder-6.7B-instruct-GGUF" {
109 16_000
110 } else {
111 32_768
112 }
113 }
114
115 /// Context window advertised for siGit Code Cloud tiers.
116 ///
117 /// Deliberately a value of its own rather than the compaction budget: the
118 /// budget is when siGit Code summarizes history, not how much the model can
119 /// hold.
120 /// Deriving the label from it made a user-facing number move whenever that
121 /// tuning constant changed, and understated the window by an order of
122 /// magnitude.
123 pub(crate) const CLOUD_CONTEXT_WINDOW_TOKENS: u64 = 200_000;
124
125 /// Render a context window as a short badge, e.g. `24K ctx`.
126 ///
127 /// Shared by the TUI `/models` picker and the ACP session-config labels so a
128 /// model reads the same in both editors.
129 pub(crate) fn format_context_window(tokens: u64) -> String {
130 format!("{} ctx", format_context_window_short(tokens))
131 }
132
133 /// The same magnitude without the `ctx` suffix, for places that supply their
134 /// own wording (e.g. "200K context").
135 pub(crate) fn format_context_window_short(tokens: u64) -> String {
136 if tokens >= 1_000 {
137 format!("{}K", tokens / 1_000)
138 } else {
139 tokens.to_string()
140 }
141 }
142
143 // ── Builder ───────────────────────────────────────────────────────────────────
144
145 /// collect every model the picker should show: local cache, remote available, fallback.
146 /// sorted by source (Onde > HF > Available > Fallback), then alphabetically.
147 pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
148 let mut items = Vec::new();
149
150 // ── 1. Locally discovered models ─────────────────────────────────────
151 for discovered in crate::setup::discover_local_models() {
152 if let Some(item) = discovered_model_to_picker_item(discovered) {
153 items.push(item);
154 }
155 }
156
157 // ── 2. Supported models not yet downloaded ───────────────────────────
158 for info in onde::inference::models::SUPPORTED_MODEL_INFO {
159 let already_present = items.iter().any(|item| item.config.model_id == info.id);
160 if already_present {
161 continue;
162 }
163
164 let config = match model_id_to_config(info.id) {
165 Some(config) => config,
166 None => continue,
167 };
168
169 let tool_calling = is_tool_calling(info.id);
170 let max_tokens = max_tokens_for(info.id);
171 let context_window_tokens = context_window_tokens_for(info.id);
172
173 items.push(ModelPickerItem {
174 display_name: config.display_name.clone(),
175 description: config.approx_memory.clone(),
176 tool_calling,
177 max_tokens,
178 context_window_tokens,
179 config,
180 source_label: "Onde".to_string(),
181
182 source: ModelSource::Available,
183 cache_health: ModelCacheHealth::NotDownloaded,
184 cloud_tier: None,
185 });
186 }
187
188 // ── 3. Fallback (on-device default when nothing else is present) ─────
189 if items.is_empty() {
190 let config = GgufModelConfig::platform_default();
191 let tool_calling = is_tool_calling(&config.model_id);
192 let max_tokens = max_tokens_for(&config.model_id);
193 let context_window_tokens = context_window_tokens_for(&config.model_id);
194
195 items.push(ModelPickerItem {
196 display_name: config.display_name.clone(),
197 description: config.approx_memory.clone(),
198 tool_calling,
199 max_tokens,
200 context_window_tokens,
201 config,
202 source_label: "Platform default".to_string(),
203
204 source: ModelSource::Fallback,
205 cache_health: ModelCacheHealth::Complete,
206 cloud_tier: None,
207 });
208 }
209
210 // ── 4. siGit Code Cloud tiers (always offered; sign-in gated at select) ─
211 for tier in crate::provider::CLOUD_TIERS {
212 let label = crate::provider::cloud_tier_label(tier);
213 // Synthetic config: a `sigit-cloud:<tier>` id never collides with a real
214 // HuggingFace id (no `/`), so on-device matching code stays inert.
215 let config = GgufModelConfig {
216 model_id: format!("sigit-cloud:{tier}"),
217 files: Vec::new(),
218 tok_model_id: None,
219 display_name: label.clone(),
220 approx_memory: "Cloud".to_string(),
221 chat_template: None,
222 };
223 items.push(ModelPickerItem {
224 display_name: label,
225 description: "siGit Code Cloud".to_string(),
226 tool_calling: true,
227 max_tokens: 4096,
228 context_window_tokens: CLOUD_CONTEXT_WINDOW_TOKENS,
229 config,
230 source_label: "siGit Code Cloud".to_string(),
231 source: ModelSource::Cloud,
232 cache_health: ModelCacheHealth::Complete,
233 cloud_tier: Some((*tier).to_string()),
234 });
235 }
236
237 // Group by inference nature, active mode first (so the highlighted group
238 // leads the picker), then by source, then alphabetically.
239 let active = active_inference_kind();
240 let group_rank =
241 |item: &ModelPickerItem| -> u8 { if item.source.kind() == active { 0 } else { 1 } };
242 items.sort_by(|left, right| {
243 group_rank(left)
244 .cmp(&group_rank(right))
245 .then_with(|| left.source.cmp(&right.source))
246 .then_with(|| left.display_name.cmp(&right.display_name))
247 });
248
249 items
250 }
251
252 /// Picker items restricted to on-device models (no cloud tiers). Used by the
253 /// model-loading and ACP session-config paths, which only handle local GGUF
254 /// models. The cloud tiers are an interactive TUI-picker feature.
255 pub(crate) fn local_picker_items() -> Vec<ModelPickerItem> {
256 build_model_picker_items()
257 .into_iter()
258 .filter(|item| item.cloud_tier.is_none())
259 .collect()
260 }
261
262 // ── Internal helpers ──────────────────────────────────────────────────────────
263
264 fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
265 let source_label = if model.from_app_group {
266 "Onde".to_string()
267 } else {
268 "HuggingFace".to_string()
269 };
270
271 let config = model_id_to_config(&model.model_id)?;
272
273 let tool_calling = is_tool_calling(&model.model_id);
274 let max_tokens = max_tokens_for(&model.model_id);
275 let context_window_tokens = context_window_tokens_for(&model.model_id);
276
277 Some(ModelPickerItem {
278 display_name: config.display_name.clone(),
279 description: config.approx_memory.clone(),
280 tool_calling,
281 max_tokens,
282 context_window_tokens,
283 config,
284 source_label,
285
286 source: if model.from_app_group {
287 ModelSource::Onde
288 } else {
289 ModelSource::HuggingFace
290 },
291 cache_health: model.cache_health,
292 cloud_tier: None,
293 })
294 }