Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,30 @@ Always set `family`, `task`, and `mode` explicitly rather than relying on inferr
names or defaults. Several entries can use the same family with different GGUFs,
tasks, or modes, provided each entry has a unique `id`.

### Default session options

Top-level `"default_session_options"` sets session options for every model of a
family, keyed `<family>.<option>`. A model gets the entries of its family that its
own `session_options` do not set. This applies to the configured models and to
models loaded later through `POST /v1/models/load`, which is how the WebUI loads
a model with `--ui-management`. The WebUI sends the catalog's session options,
not the config's, so without defaults such a load drops settings like memory
options:

```json
{
"ui_management": true,
"default_session_options": {
"irodori_tts.mem_saver": true,
"irodori_tts.codec_decode_chunk_steps": 100
},
"models": []
}
```

A key without a family prefix is rejected when the config loads. A value for a
family that never loads is ignored.

### Paths and model specs

Relative model and configured voice-library paths resolve against the config
Expand Down Expand Up @@ -267,6 +291,7 @@ endpoint, or `request.options` on generic task endpoints.
| `--task tts --mode offline` | `models[].task` and `models[].mode` | Choose the matching entry; a request does not change its session mode. |
| `--load-option key=value` | `models[].load_options` | Not an inference-request option. |
| `--session-option family.key=value` | `models[].session_options` | Not an inference-request option. |
| `--session-option family.key=value` for every model of that family | top-level [`default_session_options`](#default-session-options) | Not an inference-request option. |
| `--request-option key=value` | `models[].default_request_options` | Speech `options`, or generic `request.options`. |
| `--seed 1234`, `--max-tokens 512` | `default_request_options`: `seed`, `max_tokens` | Speech also accepts these as top-level fields. |
| `--instruct "..."` | `default_request_options`: use the model's documented option, usually `instruct` | `options.instruct` for a model accepting `instruct`; see the speech endpoint for the distinct `instructions` field. |
Expand Down
26 changes: 26 additions & 0 deletions app/server/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,20 @@ ServerConfig load_server_config(const std::filesystem::path & path, bool paralle
config.model_spec_override = selection == "embedded"
? std::filesystem::path(selection) : resolve_path(base, selection);
}
if (const auto * value = root.find("default_session_options")) {
if (!value->is_object()) {
throw std::runtime_error("server default_session_options must be an object");
}
config.default_session_options = options_from_object(value);
for (const auto & [key, option] : config.default_session_options) {
(void) option;
const auto dot = key.find('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == key.size()) {
throw std::runtime_error(
"server default_session_options key must be <family>.<option>: " + key);
}
}
}
if (const auto * value = root.find("voice_dir")) {
if (!value->is_string()) {
throw std::runtime_error("server voice_dir must be a string");
Expand Down Expand Up @@ -385,7 +399,19 @@ ServerConfig load_server_config(const std::filesystem::path & path, bool paralle
}
config.models.push_back(std::move(model));
}
for (auto & model : config.models) {
apply_default_session_options(config, model);
}
return config;
}

void apply_default_session_options(const ServerConfig & config, ServerModelConfig & model) {
const std::string prefix = model.family + ".";
for (const auto & [key, value] : config.default_session_options) {
if (key.rfind(prefix, 0) == 0) {
model.session_options.emplace(key, value);
}
}
}

} // namespace minitts::server
7 changes: 7 additions & 0 deletions app/server/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ struct ServerConfig {
std::string frontend_listener;
ServerFrontendOptions frontend_options;
std::optional<std::filesystem::path> model_spec_override;
// Session options for every model of a family that does not set them itself:
// the configured models and the ones loaded later through POST /v1/models/load
// (the WebUI). Keys are family-qualified, e.g. "irodori_tts.mem_saver".
std::unordered_map<std::string, std::string> default_session_options;
// Voice library shared across all TTS models: *.wav files plus a `prompt_text`
// mapping file (<basename>|<transcript>). A request `voice` name that is not a
// model preset resolves to <voice_dir>/<name>.wav as the cloning reference.
Expand All @@ -120,5 +124,8 @@ struct ServerConfig {
engine::core::BackendType parse_server_backend(const std::string & value);
ServerConfig load_server_config(const std::filesystem::path & path);
ServerConfig load_server_config(const std::filesystem::path & path, bool parallel_jobs);
// Adds the config.default_session_options entries for model.family that the model's
// own session_options do not set.
void apply_default_session_options(const ServerConfig & config, ServerModelConfig & model);

} // namespace minitts::server
1 change: 1 addition & 0 deletions app/server/parallel_runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ HttpResponse ParallelServerState::handle_model_load(const std::string & body_tex
requested.slots = static_cast<int>(slots->as_number());
}
requested.path = resolve_ui_model_path(engine::io::json::require_string(body, "path"));
apply_default_session_options(config_, requested);

// Validate metadata/presets before replacing any live state. A failed
// validation leaves the existing configuration and sessions intact.
Expand Down
1 change: 1 addition & 0 deletions app/server/runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ HttpResponse ServerState::handle_model_load(const std::string & body_text) {
}
auto requested = model_config_from_json(body, request_base_, false);
requested.path = resolve_ui_model_path(engine::io::json::require_string(body, "path"));
apply_default_session_options(config_, requested);

LoadedModel * existing = nullptr;
{
Expand Down
61 changes: 61 additions & 0 deletions tests/unittests/test_server_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,66 @@ void test_default_request_options() {
require(options.at("do_sample") == "false", "bool default request option parsed");
}

void test_default_session_options() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"server_default_session_options.json",
R"JSON({
"default_session_options": {
"irodori_tts.mem_saver": true,
"irodori_tts.codec_decode_chunk_steps": 100,
"qwen3_asr.max_tokens": 256
},
"models": [
{
"id": "tts",
"family": "irodori_tts",
"path": "models/irodori.gguf",
"session_options": {
"irodori_tts.codec_decode_chunk_steps": "50"
}
},
{
"id": "other",
"family": "omnivoice",
"path": "models/OmniVoice"
}
]
})JSON");

const auto config = minitts::server::load_server_config(config_path);
require(config.default_session_options.size() == 3, "default session options parsed");
const auto & tts = config.models.at(0).session_options;
require(tts.at("irodori_tts.mem_saver") == "true", "default fills an unset session option");
require(tts.at("irodori_tts.codec_decode_chunk_steps") == "50", "the model's own session option wins");
require(tts.count("qwen3_asr.max_tokens") == 0, "defaults of another family are not applied");
require(config.models.at(1).session_options.empty(), "a family without defaults is unchanged");

// Models loaded later (POST /v1/models/load) get the same treatment.
minitts::server::ServerModelConfig loaded;
loaded.family = "irodori_tts";
loaded.session_options["irodori_tts.mem_saver"] = "false";
minitts::server::apply_default_session_options(config, loaded);
require(loaded.session_options.at("irodori_tts.mem_saver") == "false", "a loaded model's own value wins");
require(loaded.session_options.at("irodori_tts.codec_decode_chunk_steps") == "100",
"a loaded model gets the family default");

for (const std::string bad : {R"({"default_session_options": {"mem_saver": true}, "models": []})",
R"({"default_session_options": {".mem_saver": true}, "models": []})",
R"({"default_session_options": {"irodori_tts.": true}, "models": []})",
R"({"default_session_options": ["irodori_tts.mem_saver"], "models": []})"}) {
const auto bad_path = write_config(root, "server_bad_default_session_options.json", bad);
bool rejected = false;
try {
(void) minitts::server::load_server_config(bad_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("default_session_options") != std::string::npos;
}
require(rejected, "malformed default_session_options is rejected: " + bad);
}
}

void test_missing_default_preset_name_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
Expand Down Expand Up @@ -680,6 +740,7 @@ int main() {
test_builtin_utility_model_paths();
test_default_preset_name();
test_default_request_options();
test_default_session_options();
test_missing_default_preset_name_is_rejected();
test_duplicate_json_keys_are_rejected();
test_busy_timeout_defaults_and_overrides();
Expand Down
Loading