Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-tts-daemon/src/daemon/http/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
//! Contract: `docs/protocol/endpoints/v1/pipeline/model.md`.
5
//!
6
//! Read it, run it, stop it, abandon a load still in flight, or re-instantiate
7
//! it in place. All of them are scoped to the stage in the path: stages
8
//! provision independently, so one stage's cancel must not abandon another's
9
//! download.
10
//!
11
//! The read is where the selection lives. `GET /pipeline/{stage}` reports the
12
//! *backend* filling the position and nothing else; what that backend is
13
//! running — and whether it is running at all — is here. This is also the read
14
//! `POST /speak` depends on: an utterance needs a stage whose model is
15
//! `loaded`.
16
17
use crate::daemon::http::internal::helpers::dispatch::{build_request, dispatch, narrowed};
18
use crate::daemon::http::state::AppState;
19
use axum::extract::{Path, State};
20
use axum::response::Response;
21
use serde::Deserialize;
22
23
use super::{Stage, unknown_stage};
24
use crate::daemon::http::v1::wire::{FromDaemon, ModelList, StageModelEnvelope, StageMutation};
25
use crate::daemon::http::wire::{Ack, ErrorEnvelope, ReasonEnvelope};
26
27
/// `GET /pipeline/{stage}/model` — the model this stage is pointed at.
28
#[utoipa::path(
29
    get,
30
    path = "/pipeline/{stage}/model",
31
    tag = "pipeline",
32
    summary = "Report a stage's model",
33
    description = "\
34
What the stage is pointed at, whether it is loaded, which accelerator it runs on, and \
35
the load still in flight — in one payload, so a settings card renders its whole Model \
36
section from a single request.
37
38
`model` is the *selection*, not the running instance: it survives an unload, so a card \
39
can offer to load the same model again — onto another device, say — without the user \
40
picking it a second time. `loaded` is what says whether it is up, and what `POST \
41
/speak` requires. `switch` can be present while `model` is still `null`; that is a \
42
stage's very first load.
43
44
`device` carries the stored preference and what it resolved to, which is all a picker \
45
needs to render its current value. What the model *could* run on is \
46
`GET /pipeline/{stage}/model/{model}/device/list`, kept separate because that answer \
47
costs a fresh probe of the host's accelerators.",
48
    params(
49
        ("stage" = u32, Path,
50
         description = "Pipeline position. `1` synthesizes — text in, audio out — and is the only position this build has; any other is a `404 unknown_stage` naming the ones that do exist.",
51
         example = 1),
52
    ),
53
    security(("session_token" = ["settings"])),
54
    responses(
55
        (status = 200, description = "The stage's model slot. `model` is `null` when nothing is selected — an empty slot, not an absent key.", body = StageModelEnvelope),
56
        (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope),
57
        (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope),
58
        (status = 404, description = "No such stage (`unknown_stage`).", body = ErrorEnvelope),
59
        (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope),
60
    ),
61
)]
62
4
pub(crate) async fn get_stage_model(State(s): State<AppState>, Path(stage): Path<u32>) -> Response {
63
4
    let Some(
cmds3
) = Stage::resolve(stage) else {
64
1
        return unknown_stage(stage);
65
    };
66
3
    let mut resp = dispatch(&s.daemon, build_request(cmds.get_model, None)).await;
67
    // An idle stage is not a failure, and the command still says it is: with
68
    // nothing loaded it answers "No model is currently loaded" as an *error*
69
    // envelope, while attaching the slot anyway. The slot is the answer — a
70
    // stage with nothing selected has a slot with nothing in it — so a response
71
    // carrying one is a success however it labelled itself.
72
    //
73
    // The endpoint this replaced compensated the same way. Without it the very
74
    // first read on a fresh install is a `500`, and a Model card can never
75
    // render the empty state it is supposed to open in.
76
3
    if resp.stage_model.is_some() && resp.status != "success" {
77
3
        resp.status = "success".to_string();
78
3
        resp.error_code = None;
79
3
    
}0
80
3
    narrowed(resp, StageModelEnvelope::from_daemon)
81
4
}
82
83
/// Which model to run in the stage.
84
#[derive(Deserialize, utoipa::ToSchema)]
85
pub(crate) struct SetModelBody {
86
    /// The model's name, as `GET /pipeline/{stage}/model/list` spells it.
87
    #[schema(example = "kokoro-82m")]
88
    pub(crate) model: String,
89
    /// The backend serving it. Omitted resolves to the backend already selected
90
    /// for this stage.
91
    #[serde(default)]
92
    #[schema(example = "github.com/jorge-menjivar/super-tts-kokoro")]
93
    pub(crate) source: Option<String>,
94
}
95
96
/// `POST /pipeline/{stage}/model` — run a model in this stage.
97
#[utoipa::path(
98
    post,
99
    path = "/pipeline/{stage}/model",
100
    tag = "pipeline",
101
    summary = "Run a model in a stage",
102
    description = "\
103
Loads `model` into the stage, downloading it first if this machine does not have it \
104
yet. That download can be long, so the switch runs on: watch the `download_progress` \
105
and `daemon_status_changed` topics on `GET /events`, or poll \
106
`GET /pipeline/{stage}/model` and read `switch`. Abandon it with \
107
`POST /pipeline/{stage}/model/cancel`.
108
109
Omitting `source` uses the backend already selected for the stage; naming one also \
110
selects that backend, so a client can go straight from a flat model picker to a load. \
111
Two backends may serve the same model name, which is why an omitted `source` resolves \
112
against the stage's selection rather than by scanning for whoever serves the name.
113
114
A load that fails leaves the backend selected and *no* model loaded. The daemon does \
115
not quietly restore whatever was running before, because a client that asked for a \
116
switch and was told it succeeded would then be speaking in the old voice.
117
118
Anything being spoken is stopped first: the switch is the newer request, the same way \
119
a second `POST /speak` supersedes the first. Asking for the model that is already \
120
running changes nothing and interrupts nothing.
121
122
The model loads on its own device — set that first with \
123
`POST /pipeline/{stage}/model/{model}/device`, which is also how to move a model that \
124
is already running.",
125
    params(
126
        ("stage" = u32, Path,
127
         description = "Pipeline position. `1` synthesizes — text in, audio out — and is the only position this build has; any other is a `404 unknown_stage` naming the ones that do exist.",
128
         example = 1),
129
    ),
130
    request_body = SetModelBody,
131
    security(("session_token" = ["settings"])),
132
    responses(
133
        (status = 200, description = "Accepted. The model may still be downloading — read `switch` on `GET /pipeline/{stage}/model` to follow it.", body = StageMutation),
134
        (status = 400, description = "`model` was missing or empty (`invalid_value`); no installed backend serves `(model, source)` in this stage's role (`invalid_model`); `source` was omitted and the stage has no backend (`invalid_backend`); or the model is online and `allow_online_models` is off (`online_models_disabled`).", body = ErrorEnvelope),
135
        (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope),
136
        (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope),
137
        (status = 404, description = "No such stage (`unknown_stage`).", body = ErrorEnvelope),
138
        (status = 409, description = "This stage already has a load in flight (`switch_in_progress`).", body = ErrorEnvelope),
139
        (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope),
140
    ),
141
)]
142
1
pub(crate) async fn set_stage_model(
143
1
    State(s): State<AppState>,
144
1
    Path(stage): Path<u32>,
145
1
    axum::Json(body): axum::Json<SetModelBody>,
146
1
) -> Response {
147
1
    let Some(cmds) = Stage::resolve(stage) else {
148
0
        return unknown_stage(stage);
149
    };
150
    // `source` is added only when the client sent one: the key's *absence* is
151
    // what tells the daemon to resolve against the stage's own backend, where a
152
    // `null` would read as a request to load from no backend at all.
153
1
    let mut data = serde_json::json!({ "model": body.model });
154
1
    if let Some(source) = body.source {
155
1
        data["source"] = serde_json::Value::String(source);
156
1
    
}0
157
1
    let resp = dispatch(&s.daemon, build_request(cmds.set_model, Some(data))).await;
158
1
    narrowed(resp, StageMutation::from_daemon)
159
1
}
160
161
/// `DELETE /pipeline/{stage}/model` — stop this stage, keeping its backend.
162
#[utoipa::path(
163
    delete,
164
    path = "/pipeline/{stage}/model",
165
    tag = "pipeline",
166
    summary = "Stop a stage's model",
167
    description = "\
168
Unloads the model, freeing its device memory, and leaves the backend selected so \
169
another of its models — or the same one, on another device — can be loaded without \
170
re-selecting it.
171
172
This is what a Stop button should call, and it stops whatever is being spoken first — \
173
a Stop that left the audio playing would not be one. Emptying the stage entirely, \
174
forgetting the backend along with the model, is `DELETE /pipeline/{stage}`.",
175
    params(
176
        ("stage" = u32, Path,
177
         description = "Pipeline position. `1` synthesizes — text in, audio out — and is the only position this build has; any other is a `404 unknown_stage` naming the ones that do exist.",
178
         example = 1),
179
    ),
180
    security(("session_token" = ["settings"])),
181
    responses(
182
        (status = 200, description = "Unloaded. The backend is still selected.", body = StageMutation),
183
        (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope),
184
        (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope),
185
        (status = 404, description = "No such stage (`unknown_stage`).", body = ErrorEnvelope),
186
        (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope),
187
    ),
188
)]
189
1
pub(crate) async fn clear_stage_model(
190
1
    State(s): State<AppState>,
191
1
    Path(stage): Path<u32>,
192
1
) -> Response {
193
1
    let Some(
cmds0
) = Stage::resolve(stage) else {
194
1
        return unknown_stage(stage);
195
    };
196
0
    let resp = dispatch(&s.daemon, build_request(cmds.clear_model, None)).await;
197
0
    narrowed(resp, StageMutation::from_daemon)
198
1
}
199
200
/// `POST /pipeline/{stage}/model/cancel` — abandon the load this stage has in
201
/// flight. Scoped to the stage: stages provision independently, so one stage's
202
/// cancel is not a licence to abandon another's download.
203
#[utoipa::path(
204
    post,
205
    path = "/pipeline/{stage}/model/cancel",
206
    tag = "pipeline",
207
    summary = "Abandon a stage's in-flight load",
208
    description = "\
209
Stops the download or load this stage has in flight, leaving the stage with whatever \
210
it had before the switch started. Scoped to the stage: stages provision independently, \
211
so cancelling one is not a licence to abandon another's download.
212
213
`409 no_switch_in_progress` when the stage has nothing in flight, and \
214
`409 switch_finalizing` once the switch has passed the point where stopping it would \
215
leave a half-written model on disk.",
216
    params(
217
        ("stage" = u32, Path,
218
         description = "Pipeline position. `1` synthesizes — text in, audio out — and is the only position this build has; any other is a `404 unknown_stage` naming the ones that do exist.",
219
         example = 1),
220
    ),
221
    security(("session_token" = ["settings"])),
222
    responses(
223
        (status = 200, description = "Cancelled.", body = Ack),
224
        (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope),
225
        (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope),
226
        (status = 404, description = "No such stage (`unknown_stage`).", body = ErrorEnvelope),
227
        (status = 409, description = "Nothing was in flight for this stage (`no_switch_in_progress`), or it is past the cancellable window (`switch_finalizing`).", body = ErrorEnvelope),
228
        (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope),
229
    ),
230
)]
231
1
pub(crate) async fn cancel_stage_model(
232
1
    State(s): State<AppState>,
233
1
    Path(stage): Path<u32>,
234
1
) -> Response {
235
1
    let Some(cmds) = Stage::resolve(stage) else {
236
0
        return unknown_stage(stage);
237
    };
238
1
    let resp = dispatch(&s.daemon, build_request(cmds.cancel_model, None)).await;
239
1
    narrowed(resp, Ack::from_daemon)
240
1
}
241
242
/// `POST /pipeline/{stage}/model/reload` — re-instantiate in place, picking up
243
/// changed secrets and options without a manual unload/load.
244
#[utoipa::path(
245
    post,
246
    path = "/pipeline/{stage}/model/reload",
247
    tag = "pipeline",
248
    summary = "Re-instantiate a stage's model in place",
249
    description = "\
250
Tears the model down and brings it back up as the same `(model, source)` on the same \
251
device, so it picks up a changed secret or option — an API key written through \
252
`/backend/{backend_id}/secret/{name}`, say — without the client unloading and \
253
reloading by hand.
254
255
Synchronous, unlike `POST /pipeline/{stage}/model`: the response is sent once the \
256
model is back up, and the daemon then broadcasts the same `model_switched` and `ready` \
257
events a completed switch does, so subscribers converge on one state either way. \
258
Nothing is re-downloaded; the files on disk are unchanged, and a stage with nothing \
259
loaded answers `200` having done nothing. Anything the model is saying is stopped \
260
first, since the instance saying it is the one being torn down.
261
262
Rarely needed by hand — writing a backend option or secret already reloads every stage \
263
running a model from that backend.",
264
    params(
265
        ("stage" = u32, Path,
266
         description = "Pipeline position. `1` synthesizes — text in, audio out — and is the only position this build has; any other is a `404 unknown_stage` naming the ones that do exist.",
267
         example = 1),
268
    ),
269
    security(("session_token" = ["settings"])),
270
    responses(
271
        (status = 200, description = "Reloaded, or nothing was loaded to reload; `message` says which.", body = Ack),
272
        (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope),
273
        (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope),
274
        (status = 404, description = "No such stage (`unknown_stage`).", body = ErrorEnvelope),
275
        (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope),
276
        (status = 500, description = "Re-instantiation failed; the previous instance is gone and the stage is left with nothing loaded.", body = ErrorEnvelope),
277
    ),
278
)]
279
0
pub(crate) async fn reload_stage_model(
280
0
    State(s): State<AppState>,
281
0
    Path(stage): Path<u32>,
282
0
) -> Response {
283
0
    let Some(cmds) = Stage::resolve(stage) else {
284
0
        return unknown_stage(stage);
285
    };
286
0
    let resp = dispatch(&s.daemon, build_request(cmds.reload_model, None)).await;
287
0
    narrowed(resp, Ack::from_daemon)
288
0
}
289
290
/// `GET /pipeline/{stage}/model/list` — the models this stage can run.
291
#[utoipa::path(
292
    get,
293
    path = "/pipeline/{stage}/model/list",
294
    tag = "pipeline",
295
    summary = "List the models a stage can run",
296
    description = "\
297
The models the backend filling this stage serves *in this stage's role* — synthesis \
298
models for stage 1 — as `[name, source]` pairs, which is the pair \
299
`POST /pipeline/{stage}/model` accepts. Fill a model picker from this.
300
301
Scoped twice over, and both halves matter. A model from another backend cannot load \
302
here without also changing the stage's backend, so offering it hands the user a pick \
303
that does something other than what the list implied. The role filter is invisible \
304
today, since every model a TTS backend serves synthesizes; it stops being invisible \
305
the moment a second position exists, and doing it in the daemon rather than in each \
306
client is what keeps the offer and the acceptance from drifting.
307
308
The full catalog — every installed backend, with the voices, language tags and device \
309
support of each — is `GET /backend/list`. This is the narrow read a stage's picker \
310
wants.",
311
    params(
312
        ("stage" = u32, Path,
313
         description = "Pipeline position. `1` synthesizes — text in, audio out — and is the only position this build has; any other is a `404 unknown_stage` naming the ones that do exist.",
314
         example = 1),
315
    ),
316
    security(("session_token" = ["settings"])),
317
    responses(
318
        (status = 200, description = "The models on offer, `[name, source]` pairs. Empty when the stage has no backend selected, which reads correctly as \"choose a backend first\".", body = ModelList),
319
        (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope),
320
        (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope),
321
        (status = 404, description = "No such stage (`unknown_stage`).", body = ErrorEnvelope),
322
        (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope),
323
    ),
324
)]
325
5
pub(crate) async fn list_stage_models(
326
5
    State(s): State<AppState>,
327
5
    Path(stage): Path<u32>,
328
5
) -> Response {
329
5
    let Some(
cmds4
) = Stage::resolve(stage) else {
330
1
        return unknown_stage(stage);
331
    };
332
4
    let resp = dispatch(&s.daemon, build_request(cmds.list_models, None)).await;
333
4
    narrowed(resp, ModelList::from_daemon)
334
5
}