Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-tts-app/src/daemon/client/v1/pipeline/model.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! `/pipeline/{stage}/model` — the model a stage is pointed at.
3
//!
4
//! Read it, run it, stop it, abandon a load still in flight, list what it could
5
//! be, or re-instantiate it in place. All of them are scoped to the stage in
6
//! the path: stages provision independently, so a Cancel under one stage's
7
//! progress bar must not abandon another stage's download.
8
//!
9
//! Emptying the stage entirely — forgetting the backend along with the model —
10
//! is [`super::stage::clear_stage_backend`], one level up the path.
11
//!
12
//! The slot carries no `source`. The backend belongs to the stage, not to the
13
//! model, so a caller that needs the pair reads it through
14
//! [`super::StageState::selection`] rather than expecting both halves from one
15
//! request.
16
17
use serde::Deserialize;
18
19
use crate::daemon::client::internal::response::{require_success, require_unit};
20
use crate::daemon::client::internal::session::with_settings_token;
21
use super_tts_shared::daemon::http_client::HttpResult;
22
use super_tts_shared::daemon::http_client::transport;
23
24
/// Which accelerator a stage's model runs on.
25
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
26
pub struct StageDevice {
27
    /// The stored preference: `cpu`, `gpu`, or `none` for a model that
28
    /// synthesizes remotely and therefore has no local device. This is what a
29
    /// device picker shows as its current value.
30
    #[serde(default)]
31
    pub preference: String,
32
    /// What a `gpu` preference resolved to once the model loaded. `None` until
33
    /// a load has confirmed one, so it is never displayed as fact before then —
34
    /// a `gpu` choice can still fall back to the CPU.
35
    #[serde(default)]
36
    pub resolved_accel: Option<String>,
37
}
38
39
/// One stage's model slot: what is selected, whether it is up, and the device
40
/// it runs on.
41
///
42
/// `model` is the *selection*, not the running instance — it survives an
43
/// unload, so the card can offer to load the same model again, onto another
44
/// device say, without the user picking it a second time. `loaded` is what says
45
/// whether it is up, and what `POST /speak` requires.
46
///
47
/// Only the fields the settings app consumes are modeled; serde ignores the
48
/// rest.
49
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
50
pub struct StageModel {
51
    /// `None` when the stage has a backend but no model picked.
52
    #[serde(default)]
53
    pub model: Option<String>,
54
    #[serde(default)]
55
    pub loaded: bool,
56
    /// `None` when nothing is selected.
57
    #[serde(default)]
58
    pub device: Option<StageDevice>,
59
}
60
61
impl StageModel {
62
    /// The accelerator the model is actually running on, or `None` when it is
63
    /// not running. What a card suffixes its "Active:" line with — the
64
    /// preference is not it, since a `gpu` choice can fall back to the CPU.
65
    #[must_use]
66
0
    pub fn running_device(&self) -> Option<&str> {
67
0
        if !self.loaded {
68
0
            return None;
69
0
        }
70
0
        self.device
71
0
            .as_ref()
72
0
            .and_then(|d| d.resolved_accel.as_deref())
73
0
            .filter(|d| !d.is_empty() && *d != "none")
74
0
    }
75
}
76
77
/// Wire envelope for `GET /pipeline/{stage}/model`.
78
///
79
/// Parsed here rather than off the shared `DaemonResponse`, whose fields happen
80
/// to match most envelope keys by name. That coupling is invisible and fails
81
/// soft: this endpoint's key is `model` where the shared field is
82
/// `stage_model`, so reading it that way yields `None` on every call — no
83
/// error, just a card that comes up with nothing selected after a restart the
84
/// daemon had remembered perfectly well.
85
#[derive(Debug, Clone, Deserialize)]
86
struct ModelEnvelope {
87
    #[serde(default)]
88
    status: String,
89
    #[serde(default)]
90
    model: SlotPayload,
91
}
92
93
/// The slot as it arrives. `switch` is the download poller's; the rest is
94
/// [`StageModel`]'s.
95
#[derive(Debug, Clone, Default, Deserialize)]
96
struct SlotPayload {
97
    #[serde(default)]
98
    model: Option<String>,
99
    #[serde(default)]
100
    loaded: bool,
101
    #[serde(default)]
102
    device: Option<StageDevice>,
103
    #[serde(default)]
104
    switch: Option<StageSwitch>,
105
}
106
107
/// The load this stage has in flight. Every field is optional here even where
108
/// the daemon always fills it: this shape rides along with the selection a card
109
/// draws itself from, and a progress sub-object that arrived unexpectedly empty
110
/// should cost the progress bar, not the whole card.
111
#[derive(Debug, Clone, Deserialize)]
112
struct StageSwitch {
113
    #[serde(default)]
114
    phase: String,
115
    #[serde(default)]
116
    target: serde_json::Value,
117
    #[serde(default)]
118
    started_at: Option<String>,
119
    #[serde(default)]
120
    download: Option<StageDownload>,
121
    #[serde(default)]
122
    load: Option<super_tts_shared::models::protocol::LoadProgress>,
123
}
124
125
#[derive(Debug, Clone, Deserialize)]
126
struct StageDownload {
127
    current_file: String,
128
    file_index: usize,
129
    total_files: usize,
130
    bytes_downloaded: u64,
131
    total_bytes: u64,
132
    percentage: f32,
133
    eta_seconds: Option<u64>,
134
}
135
136
0
fn model_path(stage: u32) -> String {
137
0
    format!("/pipeline/{stage}/model")
138
0
}
139
140
/// Read `stage`'s model slot (HTTP `GET /pipeline/{stage}/model`).
141
0
pub async fn get_stage_model(stage: u32) -> HttpResult<StageModel> {
142
0
    with_settings_token(move |socket, token| async move {
143
0
        let envelope =
144
0
            transport::get_json::<ModelEnvelope>(socket, &token, &model_path(stage)).await?;
145
0
        if envelope.status != "success" {
146
0
            return Err(super_tts_shared::daemon::http_client::HttpError::Other(
147
0
                format!("get_stage_model: daemon answered {}", envelope.status),
148
0
            ));
149
0
        }
150
0
        Ok(StageModel {
151
0
            model: envelope.model.model,
152
0
            loaded: envelope.model.loaded,
153
0
            device: envelope.model.device,
154
0
        })
155
0
    })
156
0
    .await
157
0
}
158
159
/// The download `stage` has in flight, composed from its model slot's
160
/// `switch.download` sub-object (HTTP `GET /pipeline/{stage}/model`).
161
///
162
/// The polled counterpart of the `download_progress` event, for the ticks a
163
/// client may have missed — a page opened mid-download has no history to catch
164
/// up on and would otherwise show a stalled bar until the next event.
165
0
pub async fn get_download_status(
166
0
    stage: u32,
167
0
) -> HttpResult<Option<super_tts_shared::models::protocol::DownloadProgress>> {
168
0
    with_settings_token(move |socket, token| async move {
169
0
        let status =
170
0
            transport::get_json::<ModelEnvelope>(socket, &token, &model_path(stage)).await?;
171
0
        let Some(switch) = status.model.switch else {
172
0
            return Ok(None);
173
        };
174
0
        let Some(download) = switch.download else {
175
0
            return Ok(None);
176
        };
177
0
        let model_name = switch
178
0
            .target
179
0
            .get("model")
180
0
            .and_then(|v| v.as_str())
181
0
            .unwrap_or("")
182
0
            .to_string();
183
0
        Ok(Some(super_tts_shared::models::protocol::DownloadProgress {
184
0
            model_name,
185
0
            current_file: download.current_file,
186
0
            file_index: download.file_index,
187
0
            total_files: download.total_files,
188
0
            bytes_downloaded: download.bytes_downloaded,
189
0
            total_bytes: download.total_bytes,
190
0
            percentage: download.percentage,
191
0
            status: switch.phase,
192
0
            started_at: switch.started_at.unwrap_or_default(),
193
0
            eta_seconds: download.eta_seconds,
194
0
            // The polled `switch` shape carries no error detail; failure text
195
0
            // arrives on the `download_progress` SSE event.
196
0
            error: None,
197
0
            load: switch.load,
198
0
        }))
199
0
    })
200
0
    .await
201
0
}
202
203
/// Run `model` in `stage` (HTTP `POST /pipeline/{stage}/model`).
204
///
205
/// `source` is optional: omitted, the daemon resolves the model against the
206
/// backend already filling the stage. Naming one also selects that backend, so
207
/// a flat model picker can go straight to a load. It is the key's *absence*
208
/// that asks for resolution — a `null` would read as a request to load from no
209
/// backend at all — which is why it is inserted rather than always sent.
210
0
pub async fn set_stage_model(stage: u32, model: String, source: Option<String>) -> HttpResult<()> {
211
0
    let mut body = serde_json::json!({ "model": model });
212
0
    if let Some(source) = source {
213
0
        body["source"] = serde_json::Value::String(source);
214
0
    }
215
0
    with_settings_token(move |socket, token| {
216
0
        let body = body.clone();
217
0
        async move {
218
            // No header timeout: the daemon answers only once the switch
219
            // finishes, and provisioning may stream multi-GB weights first. The
220
            // fixed timeout would drop the connection and cancel the
221
            // daemon-side load. Progress and outcome arrive on the
222
            // `download_progress` SSE topic instead.
223
0
            let resp =
224
0
                transport::settings_post_no_timeout(socket, &token, &model_path(stage), &body)
225
0
                    .await?;
226
0
            require_unit(resp, "set_stage_model")
227
0
        }
228
0
    })
229
0
    .await
230
0
}
231
232
/// Stop running `stage`'s model, keeping the selection
233
/// (HTTP `DELETE /pipeline/{stage}/model`).
234
///
235
/// This is a card's Unload: the device memory is freed, the backend stays *and
236
/// so does the model*, so loading it again — onto another device, say — is one
237
/// click rather than a re-pick. [`super::stage::clear_stage_backend`] is the
238
/// one that forgets the selection.
239
0
pub async fn unload_stage_model(stage: u32) -> HttpResult<()> {
240
0
    with_settings_token(move |socket, token| async move {
241
0
        let resp = transport::settings_delete(socket, &token, &model_path(stage)).await?;
242
0
        require_unit(resp, "unload_stage_model")
243
0
    })
244
0
    .await
245
0
}
246
247
/// Abandon the load `stage` has in flight
248
/// (HTTP `POST /pipeline/{stage}/model/cancel`).
249
///
250
/// Addressed to a stage because stages provision independently: cancelling one
251
/// is not a licence to abandon another's download. A stage with nothing in
252
/// flight answers `409`, which surfaces here as an error rather than a silent
253
/// success — a Cancel that reported success without cancelling anything is
254
/// worse than one that says there was nothing to cancel.
255
0
pub async fn cancel_download(stage: u32) -> HttpResult<()> {
256
0
    let path = format!("/pipeline/{stage}/model/cancel");
257
0
    with_settings_token(move |socket, token| {
258
0
        let path = path.clone();
259
0
        async move {
260
0
            let resp =
261
0
                transport::settings_post(socket, &token, &path, &serde_json::json!({})).await?;
262
0
            require_unit(resp, "cancel_download")
263
0
        }
264
0
    })
265
0
    .await
266
0
}
267
268
/// Re-instantiate `stage`'s model in place
269
/// (HTTP `POST /pipeline/{stage}/model/reload`).
270
///
271
/// Same model, same device, brought back up so it picks up a changed secret or
272
/// option without the caller unloading and loading by hand. Nothing is
273
/// re-downloaded, and a stage with nothing loaded succeeds having done nothing.
274
///
275
/// Rarely needed: writing an option or a secret already reloads every stage
276
/// running a model from that backend. This is the explicit button for the case
277
/// where something changed outside the daemon's view — a keyring edited by
278
/// another tool, say.
279
0
pub async fn reload_stage_model(stage: u32) -> HttpResult<()> {
280
0
    let path = format!("/pipeline/{stage}/model/reload");
281
0
    with_settings_token(move |socket, token| {
282
0
        let path = path.clone();
283
0
        async move {
284
            // Same reasoning as `set_stage_model`: the daemon answers only once
285
            // the model is back up, and a reload of a large model can outlast
286
            // the fixed header timeout.
287
0
            let resp =
288
0
                transport::settings_post_no_timeout(socket, &token, &path, &serde_json::json!({}))
289
0
                    .await?;
290
0
            require_unit(resp, "reload_stage_model")
291
0
        }
292
0
    })
293
0
    .await
294
0
}
295
296
/// The models `stage` can run (HTTP `GET /pipeline/{stage}/model/list`).
297
///
298
/// `[name, source]` pairs from the backend filling the stage, filtered to the
299
/// ones that serve this stage's role — the exact pair
300
/// [`set_stage_model`] accepts. Empty when the stage has no backend selected,
301
/// which reads correctly as "choose a backend first".
302
///
303
/// Not the same list as [`crate::daemon::client::v1::backends::list_backends`]:
304
/// that is every installed backend's whole catalog, and a model from another
305
/// backend cannot load here without changing the stage's backend too. Offering
306
/// one hands the user a pick that does something other than what the list
307
/// implied.
308
0
pub async fn list_stage_models(stage: u32) -> HttpResult<Vec<(String, String)>> {
309
0
    let path = format!("/pipeline/{stage}/model/list");
310
0
    with_settings_token(move |socket, token| {
311
0
        let path = path.clone();
312
0
        async move {
313
0
            let resp = require_success(
314
0
                transport::settings_get(socket, &token, &path).await?,
315
0
                "list_stage_models",
316
0
            )?;
317
0
            Ok(resp.available_models.unwrap_or_default())
318
0
        }
319
0
    })
320
0
    .await
321
0
}