super-tts-daemon/src/daemon/http/v1/backends/options.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! `/backend/{backend_id}/option/list` — a backend's non-sensitive settings. |
3 | | //! |
4 | | //! Contract: `docs/protocol/endpoints/v1/backends/options.md`. |
5 | | //! |
6 | | //! The mirror image of [`super::secrets`], and deliberately shaped the same |
7 | | //! way: the same four verbs on the same two paths, differing only where the |
8 | | //! subject matter forces it. An option's value is not sensitive, so `GET` |
9 | | //! returns it and the `settings` scope is enough; a secret's is, so it never |
10 | | //! comes back and the `secrets` scope guards it. |
11 | | use super::{decode_source, find_backend, json_error, json_error_msg, ok}; |
12 | | use crate::daemon::http::internal::helpers::dispatch::dispatch_command; |
13 | | use crate::daemon::http::state::AppState; |
14 | | use crate::daemon::http::wire::{ErrorEnvelope, ReasonEnvelope}; |
15 | | use crate::tts_models::backends::DiscoveredBackend; |
16 | | use crate::tts_models::backends::manifest::OptionType; |
17 | | use axum::extract::{Path, State}; |
18 | | use axum::http::StatusCode; |
19 | | use axum::response::{IntoResponse, Response}; |
20 | | use serde::{Deserialize, Serialize}; |
21 | | use utoipa::ToSchema; |
22 | | use utoipa_axum::router::OpenApiRouter; |
23 | | use utoipa_axum::routes; |
24 | | |
25 | 90 | pub(crate) fn routes() -> OpenApiRouter<AppState> { |
26 | 90 | OpenApiRouter::new() |
27 | 90 | .routes(routes!(list_backend_options)) |
28 | 90 | .routes(routes!( |
29 | | get_backend_option, |
30 | | set_backend_option, |
31 | | delete_backend_option |
32 | | )) |
33 | 90 | } |
34 | | |
35 | | /// The value to store for an option. |
36 | | #[derive(Deserialize, ToSchema)] |
37 | | struct OptionBody { |
38 | | /// The new value. Empty is refused — clear an override with `DELETE` |
39 | | /// instead, which is the operation that reverts to the manifest default. |
40 | | #[serde(default)] |
41 | | value: String, |
42 | | } |
43 | | |
44 | | /// One option a backend declares, with the value in effect. |
45 | | #[derive(Serialize, ToSchema)] |
46 | | struct BackendOptionValue { |
47 | | /// The option's identifier, as the backend's manifest declares it. |
48 | | name: String, |
49 | | /// Human-readable label for a settings UI; falls back to `name`. |
50 | | label: String, |
51 | | /// The declared type — `string`, `integer`, or `bool`. |
52 | | #[serde(rename = "type")] |
53 | | kind: String, |
54 | | /// The manifest's default, or `null` when it declares none. |
55 | | default: Option<String>, |
56 | | /// The values this option accepts, when it accepts a closed set. Empty |
57 | | /// means any value of `type`, which is what a client renders a free-text |
58 | | /// field for; a non-empty list is a dropdown, and the only values a write |
59 | | /// will be allowed to store. |
60 | | choices: Vec<String>, |
61 | | /// Inclusive bounds for a numeric option, `null` when it declares none. |
62 | | /// A write outside them is refused. |
63 | | min: Option<f64>, |
64 | | max: Option<f64>, |
65 | | /// The increment a numeric option moves in. Present with `min` and `max` |
66 | | /// on an option a client should render as a slider rather than a field; |
67 | | /// the grid belongs to the control, and any value within the bounds |
68 | | /// stores. |
69 | | step: Option<f64>, |
70 | | /// Whether the backend refuses to load without a value. |
71 | | required: bool, |
72 | | /// What is actually in effect: the user's override if set, otherwise the |
73 | | /// default. |
74 | | value: Option<String>, |
75 | | } |
76 | | |
77 | | /// Every option a backend declares. |
78 | | #[derive(Serialize, ToSchema)] |
79 | | struct BackendOptions { |
80 | | #[schema(example = "success")] |
81 | | status: &'static str, |
82 | | options: Vec<BackendOptionValue>, |
83 | | } |
84 | | |
85 | | /// One option's effective value. |
86 | | #[derive(Serialize, ToSchema)] |
87 | | struct OptionValue { |
88 | | #[schema(example = "success")] |
89 | | status: &'static str, |
90 | | name: String, |
91 | | /// What is in effect now. |
92 | | value: Option<String>, |
93 | | /// The manifest default, so a UI can show what clearing would revert to. |
94 | | default: Option<String>, |
95 | | } |
96 | | |
97 | | /// Compute the effective value + metadata for a single option. |
98 | | /// |
99 | | /// Returns `None` if `name` is not declared by the backend. |
100 | 37 | fn effective( |
101 | 37 | b: &DiscoveredBackend, |
102 | 37 | cfg_value: Option<&str>, |
103 | 37 | name: &str, |
104 | 37 | ) -> Option<BackendOptionValue> { |
105 | 91 | let opt36 = b.options.iter()37 .find37 (|o| o.name == name)?1 ; |
106 | 36 | let default = opt.default.as_ref().map(ToString::to_string); |
107 | 36 | let value = cfg_value.map(str::to_string).or_else(|| default16 .clone16 ()); |
108 | | Some(BackendOptionValue { |
109 | 36 | name: opt.name.clone(), |
110 | 36 | label: opt.label.clone().unwrap_or_else(|| opt.name0 .clone0 ()), |
111 | 36 | kind: opt.r#type.map_or("string", OptionType::as_str).to_string(), |
112 | 36 | default, |
113 | 36 | choices: opt.choices.iter().map(ToString::to_string).collect(), |
114 | 36 | min: opt.min, |
115 | 36 | max: opt.max, |
116 | 36 | step: opt.step, |
117 | 36 | required: opt.required, |
118 | 36 | value, |
119 | | }) |
120 | 37 | } |
121 | | |
122 | | #[utoipa::path( |
123 | | get, |
124 | | path = "/backend/{backend_id}/option/list", |
125 | | tag = "backends", |
126 | | summary = "List a backend's options", |
127 | | description = "\ |
128 | | Every option this backend declares, each with its label, type, default, `choices`, \ |
129 | | and the value actually in effect. This is what a settings UI renders a form from: an \ |
130 | | empty `choices` is a text field, a non-empty one is a dropdown offering exactly those \ |
131 | | values. |
132 | | |
133 | | Options are the backend's own configuration — an endpoint URL, a speaking-style \ |
134 | | preset, a timeout — declared in its manifest and injected as `x-tts-option-*` headers \ |
135 | | on every request. Credentials are not options; those are secrets, at \ |
136 | | `/backend/{backend_id}/secret/list`.", |
137 | | params( |
138 | | ("backend_id" = String, Path, |
139 | | description = "The backend's id — its `source` as `GET /backend/list` reports it — percent-encoded, e.g. `github.com%2Fsuper-tts%2Fopenai`.", |
140 | | example = "github.com%2Fsuper-tts%2Fopenai"), |
141 | | ), |
142 | | security(("session_token" = ["settings"])), |
143 | | responses( |
144 | | (status = 200, description = "The backend's options.", body = BackendOptions), |
145 | | (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope), |
146 | | (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope), |
147 | | (status = 404, description = "No installed backend has that `source` (`unknown_backend`).", body = ErrorEnvelope), |
148 | | (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope), |
149 | | ), |
150 | | )] |
151 | 2 | async fn list_backend_options(State(s): State<AppState>, Path(source): Path<String>) -> Response { |
152 | 2 | let source = decode_source(&source); |
153 | 2 | let Some(b) = find_backend(&s, &source).await else { |
154 | 0 | return json_error(StatusCode::NOT_FOUND, "unknown_backend"); |
155 | | }; |
156 | 2 | let cfg = s.daemon.config.read().await; |
157 | 2 | let out: Vec<_> = b |
158 | 2 | .options |
159 | 2 | .iter() |
160 | 10 | .filter_map2 (|o| effective(&b, cfg.backend_option(&source, &o.name), &o.name)) |
161 | 2 | .collect(); |
162 | 2 | ok(&BackendOptions { |
163 | 2 | status: "success", |
164 | 2 | options: out, |
165 | 2 | }) |
166 | 2 | } |
167 | | |
168 | | #[utoipa::path( |
169 | | get, |
170 | | path = "/backend/{backend_id}/option/{name}", |
171 | | tag = "backends", |
172 | | summary = "Read one option's effective value", |
173 | | description = "\ |
174 | | The value in effect for a single option — the stored override when there is one, the \ |
175 | | manifest default otherwise — alongside that default, so a UI can show what clearing it \ |
176 | | would revert to. |
177 | | |
178 | | The values this option accepts are not repeated here. Read `choices` from \ |
179 | | `GET /backend/{backend_id}/option/list`, which is the call a picker is built from \ |
180 | | anyway.", |
181 | | params( |
182 | | ("backend_id" = String, Path, |
183 | | description = "The backend's id — its `source` as `GET /backend/list` reports it — percent-encoded, e.g. `github.com%2Fsuper-tts%2Fopenai`.", |
184 | | example = "github.com%2Fsuper-tts%2Fopenai"), |
185 | | ("name" = String, Path, description = "The option's name, as the backend's manifest declares it."), |
186 | | ), |
187 | | security(("session_token" = ["settings"])), |
188 | | responses( |
189 | | (status = 200, description = "The option's effective value.", body = OptionValue), |
190 | | (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope), |
191 | | (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope), |
192 | | (status = 404, description = "No such backend (`unknown_backend`) or no such option (`unknown_option`).", body = ErrorEnvelope), |
193 | | (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope), |
194 | | ), |
195 | | )] |
196 | 11 | async fn get_backend_option( |
197 | 11 | State(s): State<AppState>, |
198 | 11 | Path((source, name)): Path<(String, String)>, |
199 | 11 | ) -> Response { |
200 | 11 | get_backend_option_inner(s, decode_source(&source), name).await |
201 | 11 | } |
202 | | |
203 | 28 | async fn get_backend_option_inner(s: AppState, source: String, name: String) -> Response { |
204 | 28 | let Some(b27 ) = find_backend(&s, &source).await else { |
205 | 1 | return json_error(StatusCode::NOT_FOUND, "unknown_backend"); |
206 | | }; |
207 | 27 | let cfg = s.daemon.config.read().await; |
208 | 27 | match effective(&b, cfg.backend_option(&source, &name), &name) { |
209 | 26 | Some(v) => ok(&OptionValue { |
210 | 26 | status: "success", |
211 | 26 | name, |
212 | 26 | value: v.value, |
213 | 26 | default: v.default, |
214 | 26 | }), |
215 | 1 | None => json_error(StatusCode::NOT_FOUND, "unknown_option"), |
216 | | } |
217 | 28 | } |
218 | | |
219 | | #[utoipa::path( |
220 | | post, |
221 | | path = "/backend/{backend_id}/option/{name}", |
222 | | tag = "backends", |
223 | | summary = "Override an option", |
224 | | description = "\ |
225 | | Stores a value for this option, overriding the manifest default. Answers with the \ |
226 | | option's new effective value, so a UI can render the result without a second read. |
227 | | |
228 | | An option that declares `choices` takes those values and nothing else: anything \ |
229 | | outside the set is refused with `400 invalid_value`, and the message names what is on \ |
230 | | offer. An option with no `choices` takes any string. |
231 | | |
232 | | A `base_url` value is canonicalized before storage, so the field reads back the \ |
233 | | endpoint that will actually be dialed — the scheme in particular, since whether a \ |
234 | | request is encrypted should not be invisible in the field the user is looking at. A \ |
235 | | value that yields no host is stored as typed rather than refused; model load rejects \ |
236 | | it by name. Every other option is stored verbatim, whitespace included, because it may \ |
237 | | carry meaning the daemon does not interpret. |
238 | | |
239 | | If the stage is currently running a model from this backend, the daemon reloads it so \ |
240 | | the new value takes effect at once; a reload that fails is reported in the daemon's \ |
241 | | message and the old instance keeps running rather than leaving the stage empty.", |
242 | | params( |
243 | | ("backend_id" = String, Path, |
244 | | description = "The backend's id — its `source` as `GET /backend/list` reports it — percent-encoded, e.g. `github.com%2Fsuper-tts%2Fopenai`.", |
245 | | example = "github.com%2Fsuper-tts%2Fopenai"), |
246 | | ("name" = String, Path, description = "The option's name, as the backend's manifest declares it."), |
247 | | ), |
248 | | request_body = OptionBody, |
249 | | security(("session_token" = ["settings"])), |
250 | | responses( |
251 | | (status = 200, description = "Stored; this is the new effective value.", body = OptionValue), |
252 | | (status = 400, description = "The value was empty (`invalid_request`), or it is not one the option accepts (`invalid_value`): not of the declared `type`, outside a declared `min`/`max`, the option declares `choices` and the value is not one of them, it is `base_url` and names no host, or it is longer than 4000 characters or carries a control character. An option value is sent as an `x-tts-option-<name>` request header, which can hold neither a line break nor an unbounded number of bytes, so a value that cannot be delivered is refused rather than stored. Use `DELETE` to clear an override.", body = ErrorEnvelope), |
253 | | (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope), |
254 | | (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope), |
255 | | (status = 404, description = "No such backend (`unknown_backend`) or no such option (`unknown_option`).", body = ErrorEnvelope), |
256 | | (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope), |
257 | | ), |
258 | | )] |
259 | 22 | async fn set_backend_option( |
260 | 22 | State(s): State<AppState>, |
261 | 22 | Path((source, name)): Path<(String, String)>, |
262 | 22 | axum::Json(body): axum::Json<OptionBody>, |
263 | 22 | ) -> Response { |
264 | 22 | let source = decode_source(&source); |
265 | | // `base_url` is stored canonical — the same rewrite model load applies, run |
266 | | // here so the settings field reads back the endpoint that will actually be |
267 | | // dialed. The scheme is the reason it matters: a value naming none is read |
268 | | // by its host, and whether the request is encrypted is not something to |
269 | | // leave invisible in the field the user is looking at. Other options keep |
270 | | // the value verbatim, whitespace included: it may carry meaning in one the |
271 | | // daemon does not interpret. |
272 | | // |
273 | | // Validated here as well as canonicalized. A value yielding no host used to |
274 | | // be stored as typed and refused at the next model load, a fair division of |
275 | | // labour while an option write reloaded the model — it now reconfigures the |
276 | | // running one instead, so there is no later load to catch it and storing an |
277 | | // unreadable URL would report success while the backend kept its old |
278 | | // endpoint. It still cannot catch the mistake that misleads people most, a |
279 | | // well-formed URL naming the wrong port; nothing at this layer can. |
280 | 22 | let value21 = if name == crate::tts_models::backends::base_url::OPTION_NAME { |
281 | | // An all-whitespace value is not a malformed URL, it is the empty value |
282 | | // every option refuses just below — `DELETE` is how an override is |
283 | | // cleared — so it takes that answer rather than "not a URL". |
284 | 7 | match body.value.trim() { |
285 | 7 | "" => String::new1 (), |
286 | 6 | trimmed => match canonical_base_url(trimmed) { |
287 | 5 | Some(canonical) => canonical, |
288 | | None => { |
289 | 1 | return json_error_msg( |
290 | | StatusCode::BAD_REQUEST, |
291 | 1 | "invalid_value", |
292 | 1 | &format!("option `{name}` takes a URL, and {trimmed:?} is not one"), |
293 | | ); |
294 | | } |
295 | | }, |
296 | | } |
297 | | } else { |
298 | 15 | body.value.clone() |
299 | | }; |
300 | 21 | let value = value.as_str(); |
301 | 21 | if value.is_empty() { |
302 | 1 | return json_error(StatusCode::BAD_REQUEST, "invalid_request"); |
303 | 20 | } |
304 | 20 | if let Some(r0 ) = guard_missing(&s, &source, &name).await { |
305 | 0 | return r; |
306 | 20 | } |
307 | | // An option that declares `choices` accepts those and nothing else. The |
308 | | // dropdown a settings UI renders cannot offer anything else either, so |
309 | | // this is for the clients that write straight to the API — and for the |
310 | | // stored value to stay one the backend understands, since it is injected |
311 | | // into the load headers verbatim. |
312 | 20 | if let Some(r6 ) = guard_not_a_choice(&s, &source, &name, value).await { |
313 | 6 | return r; |
314 | 14 | } |
315 | 14 | let (code, _hdrs, body_str) = dispatch_command( |
316 | 14 | &s.daemon, |
317 | 14 | "set_backend_option", |
318 | 14 | Some(serde_json::json!({ "source": source, "name": name, "value": value })), |
319 | | ) |
320 | 14 | .await; |
321 | 14 | if code != StatusCode::OK { |
322 | 0 | return (code, [("content-type", "application/json")], body_str).into_response(); |
323 | 14 | } |
324 | 14 | get_backend_option_inner(s, source, name).await |
325 | 22 | } |
326 | | |
327 | | #[utoipa::path( |
328 | | delete, |
329 | | path = "/backend/{backend_id}/option/{name}", |
330 | | tag = "backends", |
331 | | summary = "Clear an option override", |
332 | | description = "\ |
333 | | Removes the stored value, reverting the option to the manifest default. Answers with \ |
334 | | the effective value that results, which is the default when one is declared and `null` \ |
335 | | when none is. |
336 | | |
337 | | Idempotent: clearing an option that has no override succeeds and reports the same \ |
338 | | value. This is also how a client returns a `choices` option to its declared default — \ |
339 | | storing a copy of the default instead would pin the user to today's value and stop a \ |
340 | | later manifest from moving it.", |
341 | | params( |
342 | | ("backend_id" = String, Path, |
343 | | description = "The backend's id — its `source` as `GET /backend/list` reports it — percent-encoded, e.g. `github.com%2Fsuper-tts%2Fopenai`.", |
344 | | example = "github.com%2Fsuper-tts%2Fopenai"), |
345 | | ("name" = String, Path, description = "The option's name, as the backend's manifest declares it."), |
346 | | ), |
347 | | security(("session_token" = ["settings"])), |
348 | | responses( |
349 | | (status = 200, description = "Cleared; this is the value now in effect.", body = OptionValue), |
350 | | (status = 401, description = "Token unknown, expired, or its binary changed.", body = ReasonEnvelope), |
351 | | (status = 403, description = "The token lacks the `settings` scope.", body = ErrorEnvelope), |
352 | | (status = 404, description = "No such backend (`unknown_backend`) or no such option (`unknown_option`).", body = ErrorEnvelope), |
353 | | (status = 429, description = "Per-client rate limit hit; back off and retry.", body = ErrorEnvelope), |
354 | | ), |
355 | | )] |
356 | 3 | async fn delete_backend_option( |
357 | 3 | State(s): State<AppState>, |
358 | 3 | Path((source, name)): Path<(String, String)>, |
359 | 3 | ) -> Response { |
360 | 3 | let source = decode_source(&source); |
361 | 3 | if let Some(r0 ) = guard_missing(&s, &source, &name).await { |
362 | 0 | return r; |
363 | 3 | } |
364 | | // Empty value clears the override → reverts to the manifest default. |
365 | 3 | let (code, _hdrs, body_str) = dispatch_command( |
366 | 3 | &s.daemon, |
367 | 3 | "set_backend_option", |
368 | 3 | Some(serde_json::json!({ "source": source, "name": name, "value": "" })), |
369 | | ) |
370 | 3 | .await; |
371 | 3 | if code != StatusCode::OK { |
372 | 0 | return (code, [("content-type", "application/json")], body_str).into_response(); |
373 | 3 | } |
374 | 3 | get_backend_option_inner(s, source, name).await |
375 | 3 | } |
376 | | |
377 | | /// The `base_url` form to store, or `None` for a value no host can be read |
378 | | /// from. |
379 | | /// |
380 | | /// Refusing is the point of the `Option`. A value that yields no host used to |
381 | | /// be stored as typed so the next model load could refuse it by name, which |
382 | | /// worked while an option write reloaded the model — it no longer does, so the |
383 | | /// write itself has to be the thing that says no. The one outcome still ruled |
384 | | /// out is dropping it silently: that would leave the backend on its built-in |
385 | | /// endpoint, sending the user's text and credentials to the vendor they had |
386 | | /// configured their way out of. |
387 | | #[cfg(feature = "wasm-backends")] |
388 | 6 | fn canonical_base_url(value: &str) -> Option<String> { |
389 | 6 | crate::tts_models::backends::base_url::normalize(value) |
390 | 6 | } |
391 | | |
392 | | /// Without the wasm transport nothing derives an endpoint from this value, so |
393 | | /// there is no canonical form to agree on and nothing to read it with — only |
394 | | /// the trim that keeps a padded value from reading back padded. |
395 | | #[cfg(not(feature = "wasm-backends"))] |
396 | | fn canonical_base_url(value: &str) -> Option<String> { |
397 | | Some(value.trim().to_string()) |
398 | | } |
399 | | |
400 | | /// Returns an error `Response` when `value` is not one the option takes — its |
401 | | /// type, its bounds or its choices, see |
402 | | /// `super_engine_spec::manifest::Opt::permits_value` — `None` when the write |
403 | | /// can proceed. |
404 | | /// |
405 | | /// Runs after [`guard_missing`], so a missing backend or option is already |
406 | | /// reported and this only ever looks at an option that exists — which is why |
407 | | /// both `None` arms here mean "nothing to object to" rather than "not found". |
408 | 20 | async fn guard_not_a_choice( |
409 | 20 | s: &AppState, |
410 | 20 | source: &str, |
411 | 20 | name: &str, |
412 | 20 | value: &str, |
413 | 20 | ) -> Option<Response> { |
414 | 20 | let backend = find_backend(s, source).await?0 ; |
415 | 60 | let opt20 = backend.options.iter()20 .find20 (|o| o.name == name)?0 ; |
416 | 20 | let refusal6 = opt.permits_value(value).err()?14 ; |
417 | 6 | Some(json_error_msg( |
418 | 6 | StatusCode::BAD_REQUEST, |
419 | 6 | "invalid_value", |
420 | 6 | &refusal, |
421 | 6 | )) |
422 | 20 | } |
423 | | |
424 | | /// Returns an error `Response` when the backend or the named option is missing, |
425 | | /// `None` when the option is present and a write can proceed. |
426 | 23 | async fn guard_missing(s: &AppState, source: &str, name: &str) -> Option<Response> { |
427 | 23 | match find_backend(s, source).await { |
428 | 0 | None => Some(json_error(StatusCode::NOT_FOUND, "unknown_backend")), |
429 | 66 | Some(b23 ) if b.options.iter()23 .any23 (|o| o.name == name)23 => None23 , |
430 | 0 | Some(_) => Some(json_error(StatusCode::NOT_FOUND, "unknown_option")), |
431 | | } |
432 | 23 | } |