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