Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-daemon/src/daemon/http/v1/wire.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! What each `settings` endpoint answers with.
3
//!
4
//! One type per shape, built from the [`DaemonResponse`] the command bus
5
//! returned. The point is that the type an endpoint *publishes* is the type its
6
//! handler *builds*: [`FromDaemon`] is the only way a narrow body comes into
7
//! existence here, so a schema cannot claim a field the handler never fills.
8
//!
9
//! Field sets are not guesses — each is the set of `with_*` calls its command
10
//! handler makes. A response carrying its value in `message` rather than in a
11
//! field of its own is marked as such, because a client has to parse it back
12
//! out.
13
14
use crate::daemon::http::wire::Ack;
15
use serde::Serialize;
16
use super_stt_shared::models::backends::BackendInfo;
17
use super_stt_shared::models::protocol::{
18
    DaemonResponse, GpuHostInfo, GpuInfo, StageModelReport, StageReport,
19
};
20
use super_stt_shared::models::theme::AudioTheme;
21
use utoipa::ToSchema;
22
23
/// Build a narrow response body from the command bus's wide one.
24
///
25
/// Implemented rather than derived so each type states which fields it takes
26
/// and what it does when one is missing — a command that stops setting a field
27
/// should surface as a documented default, not a panic.
28
pub(crate) trait FromDaemon {
29
    fn from_daemon(resp: DaemonResponse) -> Self;
30
}
31
32
impl FromDaemon for Ack {
33
0
    fn from_daemon(resp: DaemonResponse) -> Self {
34
0
        Self {
35
0
            status: "success",
36
0
            message: resp.message,
37
0
        }
38
0
    }
39
}
40
41
/// The selected audio cue theme.
42
#[derive(Serialize, ToSchema)]
43
pub(crate) struct AudioThemeState {
44
    #[schema(example = "success")]
45
    status: &'static str,
46
    /// The selected theme's token.
47
    #[schema(example = "classic")]
48
    audio_theme: String,
49
    #[serde(skip_serializing_if = "Option::is_none")]
50
    message: Option<String>,
51
}
52
53
impl FromDaemon for AudioThemeState {
54
0
    fn from_daemon(resp: DaemonResponse) -> Self {
55
0
        Self {
56
0
            status: "success",
57
0
            audio_theme: resp.audio_theme.unwrap_or_default(),
58
0
            message: resp.message,
59
0
        }
60
0
    }
61
}
62
63
/// Every audio cue theme the daemon ships.
64
#[derive(Serialize, ToSchema)]
65
pub(crate) struct AudioThemeList {
66
    #[schema(example = "success")]
67
    status: &'static str,
68
    available_audio_themes: Vec<AudioTheme>,
69
    #[serde(skip_serializing_if = "Option::is_none")]
70
    message: Option<String>,
71
}
72
73
impl FromDaemon for AudioThemeList {
74
0
    fn from_daemon(resp: DaemonResponse) -> Self {
75
0
        Self {
76
0
            status: "success",
77
0
            available_audio_themes: resp.available_audio_themes.unwrap_or_default(),
78
0
            message: resp.message,
79
0
        }
80
0
    }
81
}
82
83
/// What ends a recording.
84
#[derive(Serialize, ToSchema)]
85
pub(crate) struct RecordingStopModeState {
86
    #[schema(example = "success")]
87
    status: &'static str,
88
    recording_stop_mode: String,
89
}
90
91
impl FromDaemon for RecordingStopModeState {
92
0
    fn from_daemon(resp: DaemonResponse) -> Self {
93
0
        Self {
94
0
            status: "success",
95
0
            recording_stop_mode: resp.recording_stop_mode.unwrap_or_default(),
96
0
        }
97
0
    }
98
}
99
100
/// How transcripts reach the focused window.
101
#[derive(Serialize, ToSchema)]
102
pub(crate) struct WriteMethodState {
103
    #[schema(example = "success")]
104
    status: &'static str,
105
    write_method: String,
106
}
107
108
impl FromDaemon for WriteMethodState {
109
0
    fn from_daemon(resp: DaemonResponse) -> Self {
110
0
        Self {
111
0
            status: "success",
112
0
            write_method: resp.write_method.unwrap_or_default(),
113
0
        }
114
0
    }
115
}
116
117
/// The outcome of writing sample text with the configured method.
118
#[derive(Serialize, ToSchema)]
119
pub(crate) struct WriteMethodTest {
120
    #[schema(example = "success")]
121
    status: &'static str,
122
    /// The configured preference.
123
    #[serde(skip_serializing_if = "Option::is_none")]
124
    write_method: Option<String>,
125
    /// What that preference resolved to for this session, which can differ when
126
    /// the compositor will not permit the preferred mechanism.
127
    #[serde(skip_serializing_if = "Option::is_none")]
128
    resolved_write_method: Option<String>,
129
    #[serde(skip_serializing_if = "Option::is_none")]
130
    message: Option<String>,
131
}
132
133
impl FromDaemon for WriteMethodTest {
134
0
    fn from_daemon(resp: DaemonResponse) -> Self {
135
0
        Self {
136
0
            status: "success",
137
0
            write_method: resp.write_method,
138
0
            resolved_write_method: resp.resolved_write_method,
139
0
            message: resp.message,
140
0
        }
141
0
    }
142
}
143
144
/// How failures are announced.
145
#[derive(Serialize, ToSchema)]
146
pub(crate) struct NotificationMethodState {
147
    #[schema(example = "success")]
148
    status: &'static str,
149
    notification_method: String,
150
}
151
152
impl FromDaemon for NotificationMethodState {
153
0
    fn from_daemon(resp: DaemonResponse) -> Self {
154
0
        Self {
155
0
            status: "success",
156
0
            notification_method: resp.notification_method.unwrap_or_default(),
157
0
        }
158
0
    }
159
}
160
161
/// Whether live preview typing is on.
162
#[derive(Serialize, ToSchema)]
163
pub(crate) struct PreviewTypingState {
164
    #[schema(example = "success")]
165
    status: &'static str,
166
    preview_typing_enabled: bool,
167
    #[serde(skip_serializing_if = "Option::is_none")]
168
    message: Option<String>,
169
}
170
171
impl FromDaemon for PreviewTypingState {
172
0
    fn from_daemon(resp: DaemonResponse) -> Self {
173
0
        Self {
174
0
            status: "success",
175
0
            preview_typing_enabled: resp.preview_typing_enabled.unwrap_or(false),
176
0
            message: resp.message,
177
0
        }
178
0
    }
179
}
180
181
/// The models directory override.
182
#[derive(Serialize, ToSchema)]
183
pub(crate) struct CustomModelsDirState {
184
    #[schema(example = "success")]
185
    status: &'static str,
186
    /// The configured directory, or `null` when no override is set. Always
187
    /// present — `null` is the answer, not an absent key.
188
    custom_models_dir: Option<String>,
189
}
190
191
impl FromDaemon for CustomModelsDirState {
192
0
    fn from_daemon(resp: DaemonResponse) -> Self {
193
0
        Self {
194
0
            status: "success",
195
0
            // Doubly optional on the bus: the outer layer is "this command did
196
0
            // not set it", the inner is the documented nullable value.
197
0
            custom_models_dir: resp.custom_models_dir.flatten(),
198
0
        }
199
0
    }
200
}
201
202
/// Whether the periodic update check runs.
203
#[derive(Serialize, ToSchema)]
204
pub(crate) struct UpdateCheckEnabledState {
205
    #[schema(example = "success")]
206
    status: &'static str,
207
    update_check_enabled: bool,
208
}
209
210
impl FromDaemon for UpdateCheckEnabledState {
211
0
    fn from_daemon(resp: DaemonResponse) -> Self {
212
0
        Self {
213
0
            status: "success",
214
0
            update_check_enabled: resp.update_check_enabled.unwrap_or(false),
215
0
        }
216
0
    }
217
}
218
219
/// Which release channel updates come from.
220
#[derive(Serialize, ToSchema)]
221
pub(crate) struct UpdateBetaOptinState {
222
    #[schema(example = "success")]
223
    status: &'static str,
224
    update_beta_optin: String,
225
}
226
227
impl FromDaemon for UpdateBetaOptinState {
228
0
    fn from_daemon(resp: DaemonResponse) -> Self {
229
0
        Self {
230
0
            status: "success",
231
0
            update_beta_optin: resp.update_beta_optin.unwrap_or_default(),
232
0
        }
233
0
    }
234
}
235
236
/// The default transcription language.
237
#[derive(Serialize, ToSchema)]
238
pub(crate) struct LanguageState {
239
    #[schema(example = "success")]
240
    status: &'static str,
241
    /// A BCP-47 tag, `auto`, or `null` when nothing is configured. Always
242
    /// present — `null` is the answer, not an absent key.
243
    #[schema(example = "es")]
244
    language: Option<String>,
245
}
246
247
impl FromDaemon for LanguageState {
248
0
    fn from_daemon(resp: DaemonResponse) -> Self {
249
        Self {
250
            status: "success",
251
0
            language: resp
252
0
                .language
253
0
                .as_ref()
254
0
                .and_then(|v| v.as_str())
255
0
                .map(str::to_owned),
256
        }
257
0
    }
258
}
259
260
/// The models a pipeline stage can run: its backend's, carrying its role.
261
#[derive(Serialize, ToSchema)]
262
pub(crate) struct ModelList {
263
    #[schema(example = "success")]
264
    status: &'static str,
265
    /// `[name, source]` pairs. Post-processor models are excluded — they are not
266
    /// switchable transcription models, and offering one would fail every
267
    /// recording. The full catalog, roles included, is at `GET /backend/list`.
268
    #[schema(example = json!([["whisper-tiny", "github.com/super-stt/whisper"]]))]
269
    available_models: Vec<(String, String)>,
270
    #[serde(skip_serializing_if = "Option::is_none")]
271
    message: Option<String>,
272
}
273
274
impl FromDaemon for ModelList {
275
0
    fn from_daemon(resp: DaemonResponse) -> Self {
276
0
        Self {
277
0
            status: "success",
278
0
            available_models: resp.available_models.unwrap_or_default(),
279
0
            message: resp.message,
280
0
        }
281
0
    }
282
}
283
284
/// Every installed backend, with its models, options and secrets.
285
#[derive(Serialize, ToSchema)]
286
pub(crate) struct BackendCatalog {
287
    #[schema(example = "success")]
288
    status: &'static str,
289
    backends: Vec<BackendInfo>,
290
    #[serde(skip_serializing_if = "Option::is_none")]
291
    message: Option<String>,
292
}
293
294
impl FromDaemon for BackendCatalog {
295
0
    fn from_daemon(resp: DaemonResponse) -> Self {
296
        Self {
297
            status: "success",
298
            // The command builds a typed catalog and flattens it to `Value` at
299
            // the last step; this reads it straight back, so the published
300
            // schema is `BackendInfo` rather than "some JSON".
301
0
            backends: resp
302
0
                .backends
303
0
                .and_then(|v| serde_json::from_value(v).ok())
304
0
                .unwrap_or_default(),
305
0
            message: resp.message,
306
        }
307
0
    }
308
}
309
310
/// The host's GPUs and its GPU toolchain versions.
311
#[derive(Serialize, ToSchema)]
312
pub(crate) struct GpuInventory {
313
    #[schema(example = "success")]
314
    status: &'static str,
315
    /// One entry per detected GPU; empty on a host with none.
316
    gpu_info: Vec<GpuInfo>,
317
    /// Driver and runtime versions, independent of any one GPU.
318
    host: GpuHostInfo,
319
}
320
321
impl FromDaemon for GpuInventory {
322
0
    fn from_daemon(resp: DaemonResponse) -> Self {
323
0
        Self {
324
0
            status: "success",
325
0
            gpu_info: resp.gpu_info.unwrap_or_default(),
326
0
            host: resp.host.unwrap_or_default(),
327
0
        }
328
0
    }
329
}
330
331
/// The whole pipeline, in order.
332
#[derive(Serialize, ToSchema)]
333
pub(crate) struct PipelineReport {
334
    #[schema(example = "success")]
335
    status: &'static str,
336
    /// Stage 1 first. A transcript passes through these in order.
337
    pipeline: Vec<StageReport>,
338
    #[serde(skip_serializing_if = "Option::is_none")]
339
    message: Option<String>,
340
}
341
342
impl FromDaemon for PipelineReport {
343
0
    fn from_daemon(resp: DaemonResponse) -> Self {
344
0
        Self {
345
0
            status: "success",
346
0
            pipeline: resp.pipeline.unwrap_or_default(),
347
0
            message: resp.message,
348
0
        }
349
0
    }
350
}
351
352
/// One stage of the pipeline.
353
#[derive(Serialize, ToSchema)]
354
pub(crate) struct StageEnvelope {
355
    #[schema(example = "success")]
356
    pub(crate) status: &'static str,
357
    pub(crate) stage: StageReport,
358
}
359
360
/// One stage's model slot.
361
#[derive(Serialize, ToSchema)]
362
pub(crate) struct StageModelEnvelope {
363
    #[schema(example = "success")]
364
    pub(crate) status: &'static str,
365
    pub(crate) model: StageModelReport,
366
}
367
368
/// The devices a model or a stage can run on.
369
#[derive(Serialize, ToSchema)]
370
pub(crate) struct DeviceList {
371
    #[schema(example = "success")]
372
    status: &'static str,
373
    /// Accelerator tokens, e.g. `cpu`, `cuda`, `vulkan`.
374
    #[schema(example = json!(["cpu", "cuda"]))]
375
    available_devices: Vec<String>,
376
    #[serde(skip_serializing_if = "Option::is_none")]
377
    message: Option<String>,
378
}
379
380
impl FromDaemon for DeviceList {
381
0
    fn from_daemon(resp: DaemonResponse) -> Self {
382
0
        Self {
383
0
            status: "success",
384
0
            available_devices: resp.available_devices.unwrap_or_default(),
385
0
            message: resp.message,
386
0
        }
387
0
    }
388
}
389
390
/// A model's device preference, what it resolved to, and what this host can
391
/// offer it.
392
#[derive(Serialize, ToSchema)]
393
pub(crate) struct ModelDevice {
394
    #[schema(example = "success")]
395
    status: &'static str,
396
    /// The preference itself: `cpu`, `gpu`, or a specific accelerator. `none`
397
    /// for a model that runs remotely and therefore has no local device.
398
    #[serde(skip_serializing_if = "Option::is_none")]
399
    device: Option<String>,
400
    /// What a `gpu` preference resolved to once a model loaded — `cuda`,
401
    /// `rocm`, `metal`, `vulkan`. `null` while the preference is `gpu` but
402
    /// nothing has loaded yet; equal to the preference when it is `cpu`.
403
    ///
404
    /// Doubly optional because the wire distinguishes three states and a client
405
    /// reads them differently: the key absent means this response does not speak
406
    /// to the device at all, an explicit `null` means the preference is `gpu`
407
    /// and nothing has resolved it yet, and a value is the accelerator in use.
408
    /// Collapsing the first two would report "unresolved" where the daemon said
409
    /// nothing.
410
    #[allow(clippy::option_option)]
411
    #[serde(skip_serializing_if = "Option::is_none")]
412
    resolved_accel: Option<Option<String>>,
413
    /// What this host can actually offer this model — the intersection of the
414
    /// machine's accelerators and the builds the model ships. Empty for a model
415
    /// that runs remotely.
416
    #[schema(example = json!(["cpu", "cuda"]))]
417
    available_devices: Vec<String>,
418
    #[serde(skip_serializing_if = "Option::is_none")]
419
    message: Option<String>,
420
}
421
422
impl FromDaemon for ModelDevice {
423
0
    fn from_daemon(resp: DaemonResponse) -> Self {
424
0
        Self {
425
0
            status: "success",
426
0
            device: resp.device,
427
0
            resolved_accel: resp.resolved_accel,
428
0
            available_devices: resp.available_devices.unwrap_or_default(),
429
0
            message: resp.message,
430
0
        }
431
0
    }
432
}
433
434
/// The backend filling stage 1, as that stage's mutations report it.
435
#[derive(Serialize, serde::Deserialize, ToSchema)]
436
pub(crate) struct ActiveBackend {
437
    /// The backend's repo id.
438
    pub(crate) source: String,
439
    /// Its display name.
440
    pub(crate) name: String,
441
    /// Whether one of its models is currently up.
442
    pub(crate) model_loaded: bool,
443
}
444
445
/// Stage 2's state, as that stage's mutations report it.
446
#[derive(Serialize, serde::Deserialize, ToSchema)]
447
pub(crate) struct PostProcessorState {
448
    /// The user's on/off choice, which is separate from whether the model came
449
    /// up: a stage can be enabled with a failed load, and transcripts then pass
450
    /// through untouched.
451
    pub(crate) enabled: bool,
452
    /// The selected model, or `null` when none is picked.
453
    pub(crate) model: Option<String>,
454
    /// The selected backend, or `null` when the stage is empty.
455
    pub(crate) source: Option<String>,
456
    /// Whether that model is loaded and ready.
457
    pub(crate) loaded: bool,
458
}
459
460
/// The answer to a stage mutation.
461
///
462
/// The two stages answer with different keys — stage 1 with `active_backend`,
463
/// stage 2 with `post_processor` — because each grew its own endpoint before
464
/// the pipeline addressed stages by position. Both are documented rather than
465
/// reconciled: changing either is a breaking wire change. Read the stage back
466
/// with `GET /pipeline/{stage}` for the one shape both share.
467
#[derive(Serialize, ToSchema)]
468
pub(crate) struct StageMutation {
469
    #[schema(example = "success")]
470
    status: &'static str,
471
    /// Stage 1 only. `null` when the stage was emptied.
472
    ///
473
    /// Doubly optional for the same reason `resolved_accel` is: absent means
474
    /// this was a stage 2 mutation, which reports `post_processor` instead;
475
    /// `null` means stage 1 itself is now empty.
476
    #[allow(clippy::option_option)]
477
    #[serde(skip_serializing_if = "Option::is_none")]
478
    active_backend: Option<Option<ActiveBackend>>,
479
    /// Stage 2 only.
480
    #[serde(skip_serializing_if = "Option::is_none")]
481
    post_processor: Option<PostProcessorState>,
482
    #[serde(skip_serializing_if = "Option::is_none")]
483
    message: Option<String>,
484
}
485
486
impl FromDaemon for StageMutation {
487
0
    fn from_daemon(resp: DaemonResponse) -> Self {
488
        Self {
489
            status: "success",
490
0
            active_backend: resp
491
0
                .active_backend
492
0
                .map(|v| serde_json::from_value(v).unwrap_or(None)),
493
0
            post_processor: resp
494
0
                .post_processor
495
0
                .and_then(|v| serde_json::from_value(v).ok()),
496
0
            message: resp.message,
497
        }
498
0
    }
499
}
500
501
/// How one model's transcription language resolves.
502
///
503
/// The per-model endpoints answer with this under `language`, where the global
504
/// `/language` endpoints answer with a bare tag. Same field name, different
505
/// shapes: the per-model answer has to explain *why* a language is in effect,
506
/// since three settings can decide it.
507
#[derive(Serialize, serde::Deserialize, ToSchema)]
508
pub(crate) struct ModelLanguageBlock {
509
    /// Whether this model can transcribe more than one language at all. A
510
    /// monolingual model ignores every setting below.
511
    multilingual: bool,
512
    /// Which setting `effective` came from: the per-model override, the global
513
    /// setting, or the model's own default.
514
    source: String,
515
    /// The tag actually used, after resolution. `null` when the model detects
516
    /// the language itself.
517
    effective: Option<String>,
518
    /// The per-model override, or `null` when none is set.
519
    #[serde(rename = "override")]
520
    model_override: Option<String>,
521
    /// The model's own default language.
522
    primary: String,
523
}
524
525
/// The languages a model can be pinned to.
526
#[derive(Serialize, ToSchema)]
527
pub(crate) struct LanguageList {
528
    #[schema(example = "success")]
529
    status: &'static str,
530
    /// BCP-47 tags, plus the reserved `auto`. Empty for a monolingual model,
531
    /// which has nothing to choose.
532
    #[schema(example = json!(["auto", "en", "es"]))]
533
    available_languages: Vec<String>,
534
    #[serde(skip_serializing_if = "Option::is_none")]
535
    message: Option<String>,
536
}
537
538
impl FromDaemon for LanguageList {
539
0
    fn from_daemon(resp: DaemonResponse) -> Self {
540
0
        Self {
541
0
            status: "success",
542
0
            available_languages: resp.available_languages.unwrap_or_default(),
543
0
            message: resp.message,
544
0
        }
545
0
    }
546
}
547
548
/// The per-model language resolution.
549
#[derive(Serialize, ToSchema)]
550
pub(crate) struct ModelLanguageState {
551
    #[schema(example = "success")]
552
    status: &'static str,
553
    language: ModelLanguageBlock,
554
}
555
556
impl FromDaemon for ModelLanguageState {
557
0
    fn from_daemon(resp: DaemonResponse) -> Self {
558
        Self {
559
            status: "success",
560
0
            language: resp
561
0
                .language
562
0
                .and_then(|v| serde_json::from_value(v).ok())
563
0
                .unwrap_or(ModelLanguageBlock {
564
0
                    multilingual: false,
565
0
                    source: "default".to_string(),
566
0
                    effective: None,
567
0
                    model_override: None,
568
0
                    primary: String::new(),
569
0
                }),
570
        }
571
0
    }
572
}