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