super-stt-app/src/ui/views/models/status.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | use crate::core::app::AppModel; |
3 | | use crate::daemon::backends::BackendInfo; |
4 | | |
5 | | /// The required secrets / options that the user has not yet set for this |
6 | | /// backend, by human-readable label. Returned in declaration order so the |
7 | | /// inline warnings are stable across renders. Drives both the inline |
8 | | /// "{label} must be set." rows and whether the Select button is enabled. |
9 | | /// |
10 | | /// Takes the daemon-reported "is this secret configured?" map directly rather |
11 | | /// than the whole [`AppModel`] so the rule stays pure and unit-testable. |
12 | 12 | pub(super) fn unmet_requirements<'a>( |
13 | 12 | secret_configured: &std::collections::HashMap<(String, String), bool>, |
14 | 12 | backend: &'a BackendInfo, |
15 | 12 | ) -> Vec<&'a str> { |
16 | 12 | let mut out = Vec::new(); |
17 | 12 | for secret10 in &backend.secrets { |
18 | 10 | if !secret.required { |
19 | 1 | continue; |
20 | 9 | } |
21 | 9 | let configured = secret_configured |
22 | 9 | .get(&(backend.source.clone(), secret.name.clone())) |
23 | 9 | .copied() |
24 | 9 | .unwrap_or(false); |
25 | 9 | if !configured { |
26 | 5 | out.push(secret.label.as_deref().unwrap_or(&secret.name)); |
27 | 5 | }4 |
28 | | } |
29 | 12 | for option4 in &backend.options { |
30 | 4 | if !option.required { |
31 | 0 | continue; |
32 | 4 | } |
33 | 4 | let value = option.value.as_deref().unwrap_or("").trim(); |
34 | 4 | if value.is_empty() { |
35 | 3 | out.push(option.label.as_deref().unwrap_or(&option.name)); |
36 | 3 | }1 |
37 | | } |
38 | 12 | out |
39 | 12 | } |
40 | | |
41 | | #[cfg(test)] |
42 | | mod unmet_requirements_tests { |
43 | | //! Pin the rule for which secrets/options gate the per-backend Select |
44 | | //! button: only `required` ones, the daemon-reported configured map decides |
45 | | //! per-secret, and the human-readable `label` (not the wire `name`) is what |
46 | | //! surfaces. |
47 | | use super::*; |
48 | | use crate::daemon::backends::{BackendInfo, BackendModel, BackendOption, BackendSecret}; |
49 | | use std::collections::HashMap; |
50 | | |
51 | 8 | fn backend(secrets: Vec<BackendSecret>, options: Vec<BackendOption>) -> BackendInfo { |
52 | 8 | BackendInfo { |
53 | 8 | source: "github.com/super-stt/openai".to_string(), |
54 | 8 | description: String::new(), |
55 | 8 | name: "OpenAI".to_string(), |
56 | 8 | version: "1.0.0".to_string(), |
57 | 8 | kind: "wasm".to_string(), |
58 | 8 | allowed_hosts: Vec::new(), |
59 | 8 | installed_accel: Vec::new(), |
60 | 8 | models: vec![BackendModel { |
61 | 8 | name: "whisper-1".to_string(), |
62 | 8 | provider: String::new(), |
63 | 8 | supported_devices: vec!["none".to_string()], |
64 | 8 | estimated_vram_bytes: 0, |
65 | 8 | multilingual: false, |
66 | 8 | supported_languages: Vec::new(), |
67 | 8 | primary_language: String::new(), |
68 | 8 | realtime: false, |
69 | 8 | role: "transcription".into(), |
70 | 8 | }], |
71 | 8 | secrets, |
72 | 8 | options, |
73 | 8 | accepts_context: false, |
74 | 8 | } |
75 | 8 | } |
76 | | |
77 | 6 | fn secret(name: &str, label: Option<&str>, required: bool) -> BackendSecret { |
78 | 6 | BackendSecret { |
79 | 6 | name: name.to_string(), |
80 | 6 | label: label.map(str::to_string), |
81 | 6 | description: String::new(), |
82 | 6 | required, |
83 | 6 | } |
84 | 6 | } |
85 | | |
86 | 4 | fn option_value( |
87 | 4 | name: &str, |
88 | 4 | label: Option<&str>, |
89 | 4 | required: bool, |
90 | 4 | value: Option<&str>, |
91 | 4 | ) -> BackendOption { |
92 | 4 | BackendOption { |
93 | 4 | name: name.to_string(), |
94 | 4 | label: label.map(str::to_string), |
95 | 4 | description: String::new(), |
96 | 4 | r#type: None, |
97 | 4 | default: None, |
98 | 4 | choices: Vec::new(), |
99 | 4 | min: None, |
100 | 4 | max: None, |
101 | 4 | step: None, |
102 | 4 | required, |
103 | 4 | value: value.map(str::to_string), |
104 | 4 | } |
105 | 4 | } |
106 | | |
107 | | /// A required, unconfigured secret with a label surfaces with the label |
108 | | /// (not the `snake_case` wire name). |
109 | | #[test] |
110 | 1 | fn required_secret_unconfigured_surfaces_label() { |
111 | 1 | let bi = backend( |
112 | 1 | vec![secret("openai_api_key", Some("OpenAI API key"), true)], |
113 | 1 | Vec::new(), |
114 | | ); |
115 | 1 | let map: HashMap<(String, String), bool> = HashMap::new(); |
116 | | |
117 | 1 | let missing = unmet_requirements(&map, &bi); |
118 | 1 | assert_eq!(missing, vec!["OpenAI API key"]); |
119 | 1 | } |
120 | | |
121 | | /// A required secret with no label falls back to its `name`. The UI is |
122 | | /// then no worse than today but no better — every real backend should |
123 | | /// supply a label. |
124 | | #[test] |
125 | 1 | fn required_secret_without_label_falls_back_to_name() { |
126 | 1 | let bi = backend(vec![secret("openai_api_key", None, true)], Vec::new()); |
127 | 1 | let map: HashMap<(String, String), bool> = HashMap::new(); |
128 | | |
129 | 1 | let missing = unmet_requirements(&map, &bi); |
130 | 1 | assert_eq!(missing, vec!["openai_api_key"]); |
131 | 1 | } |
132 | | |
133 | | /// A required secret that's marked configured in the daemon-reported map is |
134 | | /// not surfaced as unmet — Select must be enabled. |
135 | | #[test] |
136 | 1 | fn configured_secret_is_not_unmet() { |
137 | 1 | let bi = backend( |
138 | 1 | vec![secret("openai_api_key", Some("OpenAI API key"), true)], |
139 | 1 | Vec::new(), |
140 | | ); |
141 | 1 | let mut map = HashMap::new(); |
142 | 1 | map.insert((bi.source.clone(), "openai_api_key".to_string()), true); |
143 | | |
144 | 1 | assert!(unmet_requirements(&map, &bi).is_empty()); |
145 | 1 | } |
146 | | |
147 | | /// A *non*-required secret never surfaces, configured or not — the daemon |
148 | | /// doesn't need it for a load, so it doesn't gate the Select button. |
149 | | #[test] |
150 | 1 | fn non_required_secret_never_surfaces() { |
151 | 1 | let bi = backend( |
152 | 1 | vec![secret("openai_org", Some("OpenAI Org"), false)], |
153 | 1 | Vec::new(), |
154 | | ); |
155 | 1 | let map = HashMap::new(); |
156 | | |
157 | 1 | assert!(unmet_requirements(&map, &bi).is_empty()); |
158 | 1 | } |
159 | | |
160 | | /// A required option with no effective value surfaces (its `label`); one |
161 | | /// with a value (including a manifest default) does not. |
162 | | #[test] |
163 | 1 | fn required_option_value_gating() { |
164 | 1 | let bi = backend( |
165 | 1 | Vec::new(), |
166 | 1 | vec![option_value("base_url", Some("Base URL"), true, None)], |
167 | | ); |
168 | 1 | let map = HashMap::new(); |
169 | 1 | assert_eq!(unmet_requirements(&map, &bi), vec!["Base URL"]); |
170 | | |
171 | 1 | let bi_with_value = backend( |
172 | 1 | Vec::new(), |
173 | 1 | vec![option_value( |
174 | 1 | "base_url", |
175 | 1 | Some("Base URL"), |
176 | | true, |
177 | 1 | Some("https://api.openai.com"), |
178 | | )], |
179 | | ); |
180 | 1 | assert!(unmet_requirements(&map, &bi_with_value).is_empty()); |
181 | 1 | } |
182 | | |
183 | | /// A whitespace-only value is treated as empty — `value = " "` does not |
184 | | /// satisfy a `required` option. |
185 | | #[test] |
186 | 1 | fn required_option_whitespace_is_empty() { |
187 | 1 | let bi = backend( |
188 | 1 | Vec::new(), |
189 | 1 | vec![option_value( |
190 | 1 | "base_url", |
191 | 1 | Some("Base URL"), |
192 | | true, |
193 | 1 | Some(" "), |
194 | | )], |
195 | | ); |
196 | 1 | let map = HashMap::new(); |
197 | 1 | assert_eq!(unmet_requirements(&map, &bi), vec!["Base URL"]); |
198 | 1 | } |
199 | | |
200 | | /// Multiple unmet requirements are returned in declaration order |
201 | | /// (secrets first, then options) — keeps the inline warnings stable |
202 | | /// across renders rather than depending on `HashMap` iteration order. |
203 | | #[test] |
204 | 1 | fn returns_in_declaration_order() { |
205 | 1 | let bi = backend( |
206 | 1 | vec![ |
207 | 1 | secret("alpha_key", Some("Alpha key"), true), |
208 | 1 | secret("beta_key", Some("Beta key"), true), |
209 | | ], |
210 | 1 | vec![option_value("base_url", Some("Base URL"), true, None)], |
211 | | ); |
212 | 1 | let map = HashMap::new(); |
213 | 1 | assert_eq!( |
214 | 1 | unmet_requirements(&map, &bi), |
215 | 1 | vec!["Alpha key", "Beta key", "Base URL"] |
216 | | ); |
217 | 1 | } |
218 | | } |
219 | | |
220 | | /// Readiness of the Models page, surfaced as the 4-state status dot in the |
221 | | /// title row. Strictly ordered from "least ready" to "ready": |
222 | | /// [`None`](ModelStatus::None) → [`Blocked`](ModelStatus::Blocked) → |
223 | | /// [`Idle`](ModelStatus::Idle) → [`Ready`](ModelStatus::Ready). |
224 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
225 | | pub(super) enum ModelStatus { |
226 | | /// No backend is selected. |
227 | | None, |
228 | | /// A backend is selected but at least one required secret/option is unset. |
229 | | Blocked, |
230 | | /// Backend selected and fully configured, but no model is loaded yet. |
231 | | Idle, |
232 | | /// A model is loaded and ready. |
233 | | Ready, |
234 | | } |
235 | | |
236 | | /// Compute the [`ModelStatus`] for the current app state — the same rule |
237 | | /// that drives the in-card warning rows is also what flips the dot's color. |
238 | 0 | pub(super) fn model_status(app: &AppModel) -> ModelStatus { |
239 | 0 | classify_model_status( |
240 | 0 | app.models_page.active_backend.as_deref(), |
241 | 0 | &app.backends, |
242 | 0 | &app.backend_secret_configured, |
243 | 0 | &app.current_model, |
244 | 0 | &app.current_source, |
245 | | ) |
246 | 0 | } |
247 | | |
248 | | /// Pure implementation of [`model_status`] — takes only the inputs the rule |
249 | | /// depends on so it's directly unit-testable without building an |
250 | | /// [`AppModel`]. Two arms surface as [`ModelStatus::None`]: there's no |
251 | | /// active backend at all, OR the daemon reports one but its catalog entry |
252 | | /// is gone (uninstalled while running). Both are "no backend" from the |
253 | | /// user's perspective. |
254 | 6 | pub(super) fn classify_model_status( |
255 | 6 | active_backend: Option<&str>, |
256 | 6 | backends: &[BackendInfo], |
257 | 6 | secret_configured: &std::collections::HashMap<(String, String), bool>, |
258 | 6 | current_model: &str, |
259 | 6 | current_source: &str, |
260 | 6 | ) -> ModelStatus { |
261 | 6 | let Some(active_source5 ) = active_backend else { |
262 | 1 | return ModelStatus::None; |
263 | | }; |
264 | 5 | let Some(backend4 ) = backends.iter().find(|b| b.source.as_str()4 == active_source4 ) else { |
265 | 1 | return ModelStatus::None; |
266 | | }; |
267 | 4 | if !unmet_requirements(secret_configured, backend).is_empty() { |
268 | 1 | return ModelStatus::Blocked; |
269 | 3 | } |
270 | 3 | let model_loaded = !current_model.is_empty() && current_source == active_source2 ; |
271 | 3 | if model_loaded { |
272 | 1 | ModelStatus::Ready |
273 | | } else { |
274 | 2 | ModelStatus::Idle |
275 | | } |
276 | 6 | } |
277 | | |
278 | | #[cfg(test)] |
279 | | mod model_status_tests { |
280 | | //! Pin the 4-state status-dot rule that lives in the page header. The |
281 | | //! states are: no backend selected → gray; backend selected but |
282 | | //! requirements unmet → red; ready but no model loaded → yellow; model |
283 | | //! loaded → green. |
284 | | use super::*; |
285 | | use crate::daemon::backends::{BackendInfo, BackendModel, BackendSecret}; |
286 | | use std::collections::HashMap; |
287 | | |
288 | 5 | fn backend_with_required_secret() -> BackendInfo { |
289 | 5 | BackendInfo { |
290 | 5 | source: "github.com/super-stt/openai".to_string(), |
291 | 5 | description: String::new(), |
292 | 5 | name: "OpenAI".to_string(), |
293 | 5 | version: "1.0.0".to_string(), |
294 | 5 | kind: "wasm".to_string(), |
295 | 5 | allowed_hosts: Vec::new(), |
296 | 5 | installed_accel: Vec::new(), |
297 | 5 | models: vec![BackendModel { |
298 | 5 | name: "whisper-1".to_string(), |
299 | 5 | provider: String::new(), |
300 | 5 | supported_devices: vec!["none".to_string()], |
301 | 5 | estimated_vram_bytes: 0, |
302 | 5 | multilingual: false, |
303 | 5 | supported_languages: Vec::new(), |
304 | 5 | primary_language: String::new(), |
305 | 5 | realtime: false, |
306 | 5 | role: "transcription".into(), |
307 | 5 | }], |
308 | 5 | secrets: vec![BackendSecret { |
309 | 5 | name: "openai_api_key".to_string(), |
310 | 5 | label: Some("OpenAI API key".to_string()), |
311 | 5 | description: String::new(), |
312 | 5 | required: true, |
313 | 5 | }], |
314 | 5 | options: Vec::new(), |
315 | 5 | accepts_context: false, |
316 | 5 | } |
317 | 5 | } |
318 | | |
319 | | /// No active backend → gray dot regardless of what else is in state. |
320 | | #[test] |
321 | 1 | fn no_active_backend_is_none() { |
322 | 1 | let backends = vec![backend_with_required_secret()]; |
323 | 1 | let map = HashMap::new(); |
324 | 1 | assert_eq!( |
325 | 1 | classify_model_status(None, &backends, &map, "", ""), |
326 | | ModelStatus::None, |
327 | | ); |
328 | 1 | } |
329 | | |
330 | | /// Active backend whose catalog entry is gone (e.g. uninstalled while |
331 | | /// running) still reads as "no backend" — the daemon's state is stale, |
332 | | /// and the dot shouldn't lie about readiness. |
333 | | #[test] |
334 | 1 | fn active_backend_missing_from_catalog_is_none() { |
335 | 1 | let map = HashMap::new(); |
336 | 1 | assert_eq!( |
337 | 1 | classify_model_status(Some("github.com/super-stt/openai"), &[], &map, "", "",), |
338 | | ModelStatus::None, |
339 | | ); |
340 | 1 | } |
341 | | |
342 | | /// Active backend with an unmet required secret → red, even if a model |
343 | | /// from another backend happens to be loaded (which shouldn't really |
344 | | /// happen after `set_active_backend` unloads on switch, but the dot |
345 | | /// should still reflect the current backend's state). |
346 | | #[test] |
347 | 1 | fn unmet_requirement_is_blocked() { |
348 | 1 | let backends = vec![backend_with_required_secret()]; |
349 | 1 | let map = HashMap::new(); |
350 | 1 | assert_eq!( |
351 | 1 | classify_model_status(Some("github.com/super-stt/openai"), &backends, &map, "", "",), |
352 | | ModelStatus::Blocked, |
353 | | ); |
354 | 1 | } |
355 | | |
356 | | /// All requirements satisfied but no model loaded for the active backend |
357 | | /// → yellow. |
358 | | #[test] |
359 | 1 | fn requirements_met_no_model_is_idle() { |
360 | 1 | let backends = vec![backend_with_required_secret()]; |
361 | 1 | let mut map = HashMap::new(); |
362 | 1 | map.insert( |
363 | 1 | (backends[0].source.clone(), "openai_api_key".to_string()), |
364 | | true, |
365 | | ); |
366 | 1 | assert_eq!( |
367 | 1 | classify_model_status(Some("github.com/super-stt/openai"), &backends, &map, "", "",), |
368 | | ModelStatus::Idle, |
369 | | ); |
370 | 1 | } |
371 | | |
372 | | /// A loaded model from a *different* source than the active backend is |
373 | | /// not "this backend ready" — the dot stays yellow until the user picks |
374 | | /// a model from the active backend. |
375 | | #[test] |
376 | 1 | fn loaded_model_from_other_source_is_idle() { |
377 | 1 | let backends = vec![backend_with_required_secret()]; |
378 | 1 | let mut map = HashMap::new(); |
379 | 1 | map.insert( |
380 | 1 | (backends[0].source.clone(), "openai_api_key".to_string()), |
381 | | true, |
382 | | ); |
383 | 1 | assert_eq!( |
384 | 1 | classify_model_status( |
385 | 1 | Some("github.com/super-stt/openai"), |
386 | 1 | &backends, |
387 | 1 | &map, |
388 | 1 | "voxtral-mini-latest", |
389 | 1 | "github.com/super-stt/mistral", |
390 | | ), |
391 | | ModelStatus::Idle, |
392 | | ); |
393 | 1 | } |
394 | | |
395 | | /// Requirements met *and* a model from this backend is loaded → green. |
396 | | #[test] |
397 | 1 | fn loaded_model_from_active_backend_is_ready() { |
398 | 1 | let backends = vec![backend_with_required_secret()]; |
399 | 1 | let mut map = HashMap::new(); |
400 | 1 | map.insert( |
401 | 1 | (backends[0].source.clone(), "openai_api_key".to_string()), |
402 | | true, |
403 | | ); |
404 | 1 | assert_eq!( |
405 | 1 | classify_model_status( |
406 | 1 | Some("github.com/super-stt/openai"), |
407 | 1 | &backends, |
408 | 1 | &map, |
409 | 1 | "whisper-1", |
410 | 1 | "github.com/super-stt/openai", |
411 | | ), |
412 | | ModelStatus::Ready, |
413 | | ); |
414 | 1 | } |
415 | | } |