Coverage Report

Created: 2026-10-23 22:23

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