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 | } |