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