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