voice_audio_multi/tests/unittests/test_server_config.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

1191 lines
52 KiB
C++

#include "busy_guard.h"
#include "config.h"
#include "model_memory.h"
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/options.h"
#include "engine/framework/runtime/registry.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
namespace {
void set_env(const char * name, const char * value) {
#ifdef _WIN32
_putenv_s(name, value == nullptr ? "" : value);
#else
if (value == nullptr) {
unsetenv(name);
} else {
setenv(name, value, 1);
}
#endif
}
void require(bool condition, const std::string & message) {
if (!condition) {
throw std::runtime_error(message);
}
}
std::filesystem::path make_temp_root() {
const auto root = std::filesystem::temp_directory_path() / "audiocpp_server_config_test";
std::error_code ec;
std::filesystem::remove_all(root, ec);
std::filesystem::create_directories(root);
return root;
}
std::filesystem::path write_config(
const std::filesystem::path & root,
const std::string & name,
const std::string & text) {
const auto path = root / name;
std::ofstream out(path);
if (!out) {
throw std::runtime_error("failed to create test config: " + path.string());
}
out << text;
if (!out) {
throw std::runtime_error("failed to write test config: " + path.string());
}
return path;
}
void test_inline_default_and_named_presets() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"server.json",
R"JSON({
"host": "127.0.0.1",
"port": 8080,
"backend": "cpu",
"models": [
{
"id": "tts",
"family": "omnivoice",
"path": "models/OmniVoice",
"task": "tts",
"default_voice_preset": {
"voice_ref": "voices/default.wav",
"reference_text": "default transcript"
},
"voice_presets": {
"assistant": {
"voice_ref": "voices/assistant.wav",
"reference_text": "assistant transcript"
},
"builtin": {
"voice_id": "alba"
},
"embedding": {
"options": {
"speaker_embedding_file": "voices/embedding.safetensors",
"num_steps": 8
}
}
}
}
]
})JSON");
const auto config = minitts::server::load_server_config(config_path);
require(config.models.size() == 1, "one model parsed");
const auto & model = config.models.front();
require(model.path == root / "models/OmniVoice", "model path is resolved relative to config");
require(model.default_voice_preset.has_value(), "inline default preset parsed");
require(model.default_voice_preset->voice_ref == root / "voices/default.wav", "default voice_ref path resolved");
require(model.default_voice_preset->reference_text == "default transcript", "default reference_text parsed");
require(model.voice_presets.size() == 3, "named presets parsed");
require(model.voice_presets.at("assistant").voice_ref == root / "voices/assistant.wav", "named voice_ref path resolved");
require(model.voice_presets.at("assistant").reference_text == "assistant transcript", "named reference_text parsed");
require(model.voice_presets.at("builtin").voice_id == "alba", "named voice_id parsed");
const auto & options = model.voice_presets.at("embedding").options;
require(options.size() == 2, "preset options parsed");
require(options.at("speaker_embedding_file") == "voices/embedding.safetensors",
"preset option values are kept as given, not resolved as paths");
require(options.at("num_steps") == "8", "numeric preset option parsed");
require(model.voice_presets.at("assistant").options.empty(), "presets without options have none");
}
void test_bad_preset_options_are_rejected() {
const auto root = make_temp_root();
const auto not_object = write_config(root, "server_preset_options_type.json", R"JSON({
"models": [{"id": "tts", "family": "irodori_tts", "path": "models/irodori",
"voice_presets": {"narrator": {"options": "speaker.safetensors"}}}]
})JSON");
bool rejected = false;
try {
(void) minitts::server::load_server_config(not_object);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("options must be an object") != std::string::npos;
}
require(rejected, "non-object preset options are rejected");
const auto empty = write_config(root, "server_preset_options_empty.json", R"JSON({
"models": [{"id": "tts", "family": "irodori_tts", "path": "models/irodori",
"voice_presets": {"narrator": {"options": {}}}}]
})JSON");
rejected = false;
try {
(void) minitts::server::load_server_config(empty);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("must set voice_id, voice_ref, reference_text, or options") !=
std::string::npos;
}
require(rejected, "a preset with only empty options is rejected");
std::filesystem::remove_all(root);
}
void test_model_spec_override_selection() {
const auto root = make_temp_root();
const auto path = write_config(root, "spec-override.json", R"JSON({
"model_spec_override": "embedded",
"models": [
{"id": "embedded", "family": "kokoro_tts", "path": "model.gguf", "model_spec_override": "embedded"},
{"id": "file", "family": "kokoro_tts", "path": "model.gguf", "model_spec_override": "specs/kokoro_tts.json"},
{"id": "literal", "family": "kokoro_tts", "path": "model.gguf", "model_spec_override": "./embedded"}
]
})JSON");
const auto config = minitts::server::load_server_config(path);
require(config.model_spec_override == std::filesystem::path("embedded"), "global embedded selector stays literal");
require(config.models[0].model_spec_override == std::filesystem::path("embedded"), "per-model embedded selector stays literal");
require(config.models[1].model_spec_override == root / "specs/kokoro_tts.json", "spec file still resolves relative to config");
require(config.models[2].model_spec_override == (root / "./embedded"), "literal embedded path stays a path");
std::filesystem::remove_all(root);
}
void test_builtin_utility_model_paths() {
const auto root = make_temp_root();
const auto absolute = (root / "weights" / "rnnoise.safetensors").generic_string();
const auto config_path = write_config(root, "server.json",
"{\"models\":["
"{\"id\":\"relative\",\"family\":\"builtin_audio_utils\","
"\"path\":\"weights/rnnoise.safetensors\",\"task\":\"s2s\","
"\"load_options\":{\"utility\":\"rnnoise\"}},"
"{\"id\":\"absolute\",\"family\":\"builtin_audio_utils\","
"\"path\":\"" + absolute + "\",\"task\":\"s2s\","
"\"load_options\":{\"utility\":\"rnnoise\"}}]}");
const auto config = minitts::server::load_server_config(config_path);
require(config.models.at(0).path == root / "weights/rnnoise.safetensors",
"builtin utility relative path resolves against config directory");
require(config.models.at(1).path == std::filesystem::path(absolute),
"builtin utility absolute path is preserved");
require(config.models.at(0).load_options.at("utility") == "rnnoise",
"builtin utility selector is forwarded");
}
void test_default_preset_name() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"server_named_default.json",
R"JSON({
"models": [
{
"id": "tts",
"family": "pocket_tts",
"path": "models/pocket-tts",
"voice_presets": {
"narrator": {
"voice_id": "cosette"
}
},
"default_voice_preset": "narrator"
}
]
})JSON");
const auto config = minitts::server::load_server_config(config_path);
const auto & model = config.models.front();
require(model.default_voice_preset_id == "narrator", "default preset name parsed");
require(!model.default_voice_preset.has_value(), "named default does not create inline preset");
}
void test_default_request_options() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"server_default_request_options.json",
R"JSON({
"models": [
{
"id": "tts",
"family": "omnivoice",
"path": "models/OmniVoice",
"default_request_options": {
"num_inference_steps": 8,
"temperature": 0.6,
"do_sample": false
}
}
]
})JSON");
const auto config = minitts::server::load_server_config(config_path);
const auto & options = config.models.front().default_request_options;
require(options.at("num_inference_steps") == "8", "integer default request option parsed");
require(options.at("temperature") == "0.6", "float default request option parsed");
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(
root,
"server_bad_default.json",
R"JSON({
"models": [
{
"id": "tts",
"family": "pocket_tts",
"path": "models/pocket-tts",
"voice_presets": {
"narrator": {
"voice_id": "cosette"
}
},
"default_voice_preset": "missing"
}
]
})JSON");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("does not match") != std::string::npos;
}
require(rejected, "unknown default preset name is rejected");
}
void test_duplicate_json_keys_are_rejected() {
bool rejected = false;
try {
(void) engine::io::json::parse(R"JSON({
"voice_presets": {
"cosette": {
"voice_id": "cosette"
}
},
"voice_presets": {
"anna": {
"voice_id": "anna"
}
}
})JSON");
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("duplicate json object key: voice_presets") != std::string::npos;
}
require(rejected, "duplicate json object keys are rejected");
}
const char * const kMinimalModel = R"JSON(
"models": [
{
"id": "tts",
"family": "pocket_tts",
"path": "models/pocket-tts"
}
]
)JSON";
void test_busy_timeout_defaults_and_overrides() {
const auto root = make_temp_root();
const auto default_path = write_config(
root, "busy_default.json", std::string("{") + kMinimalModel + "}");
require(
minitts::server::load_server_config(default_path).busy_timeout_ms == 300000,
"busy_timeout_ms defaults to 5 minutes when omitted");
const auto override_path = write_config(
root, "busy_override.json", std::string(R"JSON({"busy_timeout_ms": 90000,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(override_path).busy_timeout_ms == 90000,
"busy_timeout_ms is read from the config");
const auto disabled_path = write_config(
root, "busy_disabled.json", std::string(R"JSON({"busy_timeout_ms": 0,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(disabled_path).busy_timeout_ms == 0,
"busy_timeout_ms accepts 0 to disable the guard");
}
void test_max_request_body_defaults_and_overrides() {
const auto root = make_temp_root();
const auto default_path = write_config(
root, "request_body_default.json", std::string("{") + kMinimalModel + "}");
require(
minitts::server::load_server_config(default_path).max_request_body_bytes ==
minitts::server::kDefaultMaxRequestBodyBytes,
"max_request_body_bytes defaults to 2 GiB when omitted");
const auto override_path = write_config(
root,
"request_body_override.json",
std::string(R"JSON({"max_request_body_bytes": 1048576,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(override_path).max_request_body_bytes == 1048576,
"max_request_body_bytes is read from the config");
const auto zero_path = write_config(
root, "request_body_zero.json", std::string(R"JSON({"max_request_body_bytes": 0,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(zero_path).max_request_body_bytes == 0,
"max_request_body_bytes accepts 0 to reject non-empty request bodies");
}
void test_negative_max_request_body_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"request_body_negative.json",
std::string(R"JSON({"max_request_body_bytes": -1,)JSON") + kMinimalModel + "}");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("max_request_body_bytes") != std::string::npos;
}
require(rejected, "negative max_request_body_bytes is rejected");
}
void test_unsafe_numeric_max_request_body_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"request_body_unsafe_number.json",
std::string(R"JSON({"max_request_body_bytes": 9007199254740993,)JSON") + kMinimalModel + "}");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("max_request_body_bytes") != std::string::npos;
}
require(rejected, "unsafe numeric max_request_body_bytes is rejected");
}
void test_negative_busy_timeout_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
root, "busy_negative.json", std::string(R"JSON({"busy_timeout_ms": -1,)JSON") + kMinimalModel + "}");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("busy_timeout_ms") != std::string::npos;
}
require(rejected, "negative busy_timeout_ms is rejected");
}
void test_max_loaded_models_defaults_and_overrides() {
const auto root = make_temp_root();
const auto default_path = write_config(
root, "max_loaded_default.json", std::string("{") + kMinimalModel + "}");
require(
minitts::server::load_server_config(default_path).max_loaded_models == 0,
"max_loaded_models defaults to 0 (no limit) when omitted");
const auto single_path = write_config(
root, "max_loaded_single.json", std::string(R"JSON({"max_loaded_models": 1,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(single_path).max_loaded_models == 1,
"max_loaded_models accepts 1 to enforce a single resident model");
const auto multi_path = write_config(
root, "max_loaded_multi.json", std::string(R"JSON({"max_loaded_models": 3,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(multi_path).max_loaded_models == 3,
"max_loaded_models is read from the config");
}
void test_negative_max_loaded_models_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
root, "max_loaded_negative.json", std::string(R"JSON({"max_loaded_models": -1,)JSON") + kMinimalModel + "}");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("max_loaded_models") != std::string::npos;
}
require(rejected, "negative max_loaded_models is rejected");
}
void test_per_model_busy_timeout() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"busy_per_model.json",
R"JSON({
"busy_timeout_ms": 300000,
"models": [
{"id": "tts", "family": "pocket_tts", "path": "models/a", "busy_timeout_ms": 30000},
{"id": "music", "family": "pocket_tts", "path": "models/b", "busy_timeout_ms": 900000},
{"id": "asr", "family": "pocket_tts", "path": "models/c"}
]
})JSON");
const auto config = minitts::server::load_server_config(config_path);
require(config.models.at(0).busy_timeout_ms == 30000, "per-model busy_timeout_ms parsed");
require(config.models.at(1).busy_timeout_ms == 900000, "a model may exceed the top-level value");
require(
!config.models.at(2).busy_timeout_ms.has_value(),
"a model without the key inherits the top-level value");
}
void test_negative_per_model_busy_timeout_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"busy_per_model_negative.json",
R"JSON({
"models": [
{"id": "tts", "family": "pocket_tts", "path": "models/a", "busy_timeout_ms": -1}
]
})JSON");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("busy_timeout_ms for model tts") != std::string::npos;
}
require(rejected, "negative per-model busy_timeout_ms is rejected, naming the model");
}
void test_ui_configuration() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"ui.json",
R"JSON({
"ui": false,
"ui_management": true,
"models": []
})JSON");
const auto config = minitts::server::load_server_config(config_path);
require(!config.ui_enabled, "ui=false disables the embedded WebUI");
require(!config.ui_requested, "ui=false does not ask for the WebUI");
require(config.ui_management, "ui_management=true enables dynamic model management");
require(config.models.empty(), "management hosts may start without configured models");
const auto default_path = write_config(root, "ui_default.json", R"JSON({
"models": [{"id": "tts", "family": "pocket_tts", "path": "models/pocket-tts"}]
})JSON");
const auto by_default = minitts::server::load_server_config(default_path);
require(by_default.ui_enabled && !by_default.ui_requested, "the WebUI is on by default but not asked for");
const auto asked_path = write_config(root, "ui_asked.json", R"JSON({
"ui": true,
"models": [{"id": "tts", "family": "pocket_tts", "path": "models/pocket-tts"}]
})JSON");
const auto asked = minitts::server::load_server_config(asked_path);
require(asked.ui_enabled && asked.ui_requested, "ui=true asks for the WebUI");
}
void test_configured_model_fields_are_inherited() {
const auto root = make_temp_root();
const auto config_path = write_config(root, "inherit.json", R"JSON({
"models": [
{
"id": "tts",
"family": "irodori_tts",
"path": "models/irodori",
"busy_timeout_ms": 90000,
"default_request_options": {"num_steps": "16"},
"voice_presets": {"narrator": {"voice_ref": "voices/narrator.wav"}},
"default_voice_preset": "narrator"
}
]
})JSON");
const auto config = minitts::server::load_server_config(config_path);
const auto & configured = config.models.front();
// What a WebUI load request carries: no request options or presets.
minitts::server::ServerModelConfig loaded;
loaded.id = "tts";
loaded.family = "irodori_tts";
loaded.path = "models/other-irodori";
loaded.session_options = {{"irodori_tts.mem_saver", "true"}};
minitts::server::inherit_configured_model_fields(configured, loaded);
require(loaded.default_request_options.at("num_steps") == "16", "request options come from the config");
require(loaded.voice_presets.count("narrator") == 1, "voice presets come from the config");
require(loaded.default_voice_preset_id.value_or("") == "narrator", "the default preset comes from the config");
require(loaded.busy_timeout_ms.value_or(0) == 90000, "the busy timeout comes from the config");
require(loaded.path == "models/other-irodori", "the load keeps its own path");
require(loaded.session_options.at("irodori_tts.mem_saver") == "true", "the load keeps its own session options");
minitts::server::ServerModelConfig other;
other.id = "tts";
other.family = "pocket_tts";
other.path = "models/pocket-tts";
minitts::server::inherit_configured_model_fields(configured, other);
require(other.default_request_options.empty() && other.voice_presets.empty() &&
!other.default_voice_preset_id.has_value() && !other.busy_timeout_ms.has_value(),
"a load of another family takes nothing from the config");
minitts::server::ServerModelConfig design;
design.id = "tts";
design.family = "irodori_tts";
design.task = "vdes";
design.path = "models/irodori";
minitts::server::inherit_configured_model_fields(configured, design);
require(design.voice_presets.empty(), "a load of another task takes nothing from the config");
std::filesystem::remove_all(root);
}
void test_empty_models_require_ui_management() {
const auto root = make_temp_root();
const auto config_path = write_config(
root,
"empty_models.json",
R"JSON({
"models": []
})JSON");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("ui_management") != std::string::npos;
}
require(rejected, "an empty static server config requires ui_management");
}
// A request may shorten its own wait but must never lengthen it past server policy,
// otherwise a client could reintroduce the unbounded hang the guard prevents.
void test_request_timeout_is_clamped_to_policy() {
using minitts::server::resolve_busy_timeout_ms;
require(resolve_busy_timeout_ms(900000, std::nullopt) == 900000, "no request override uses policy");
require(resolve_busy_timeout_ms(900000, 60000) == 60000, "a shorter request bound is honored");
require(resolve_busy_timeout_ms(900000, 999999) == 900000, "a longer request bound is clamped");
require(resolve_busy_timeout_ms(900000, 900000) == 900000, "an equal request bound is unchanged");
// 0 means unbounded, so it must compare as +infinity rather than as a tiny value.
require(
resolve_busy_timeout_ms(900000, 0) == 900000,
"a request asking for unbounded is still capped by policy");
require(
resolve_busy_timeout_ms(0, 60000) == 60000,
"under unbounded policy a request may still bound its own wait");
require(
resolve_busy_timeout_ms(0, std::nullopt) == 0,
"unbounded policy with no override stays unbounded");
require(
resolve_busy_timeout_ms(0, 0) == 0,
"unbounded on both sides stays unbounded");
}
void test_idle_unload_ms_defaults_and_overrides() {
const auto root = make_temp_root();
const auto default_path = write_config(
root, "idle_unload_default.json", std::string("{") + kMinimalModel + "}");
require(
minitts::server::load_server_config(default_path).idle_unload_ms == 0,
"idle_unload_ms defaults to 0 (disabled) when omitted");
const auto set_path = write_config(
root, "idle_unload_set.json", std::string(R"JSON({"idle_unload_ms": 300000,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(set_path).idle_unload_ms == 300000,
"idle_unload_ms is read from the config");
}
void test_negative_idle_unload_ms_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
root, "idle_unload_negative.json", std::string(R"JSON({"idle_unload_ms": -1,)JSON") + kMinimalModel + "}");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("idle_unload_ms") != std::string::npos;
}
require(rejected, "negative idle_unload_ms is rejected");
}
void test_min_free_memory_mb_defaults_and_overrides() {
const auto root = make_temp_root();
const auto default_path = write_config(
root, "min_free_default.json", std::string("{") + kMinimalModel + "}");
require(
minitts::server::load_server_config(default_path).min_free_memory_mb == 0,
"min_free_memory_mb defaults to 0 (guard disabled) when omitted");
const auto set_path = write_config(
root, "min_free_set.json", std::string(R"JSON({"min_free_memory_mb": 256,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(set_path).min_free_memory_mb == 256,
"min_free_memory_mb is read from the config to opt into the guard");
}
void test_negative_min_free_memory_mb_is_rejected() {
const auto root = make_temp_root();
const auto config_path = write_config(
root, "min_free_negative.json", std::string(R"JSON({"min_free_memory_mb": -1,)JSON") + kMinimalModel + "}");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("min_free_memory_mb") != std::string::npos;
}
require(rejected, "negative min_free_memory_mb is rejected");
}
void test_resample_kernel_cache_mb_defaults_and_overrides() {
const auto root = make_temp_root();
const auto default_path = write_config(
root, "resample_cache_default.json", std::string("{") + kMinimalModel + "}");
require(
!minitts::server::load_server_config(default_path).resample_kernel_cache_mb.has_value(),
"resample_kernel_cache_mb stays unset when omitted so the library default applies");
const auto set_path = write_config(
root, "resample_cache_set.json", std::string(R"JSON({"resample_kernel_cache_mb": 0,)JSON") + kMinimalModel + "}");
require(
minitts::server::load_server_config(set_path).resample_kernel_cache_mb == 0,
"resample_kernel_cache_mb is read from the config, including 0 to disable the cache");
}
void test_invalid_resample_kernel_cache_mb_is_rejected() {
const auto root = make_temp_root();
for (const std::string value : {"-1", "4294967296", "32.5", "\"32\"", "null"}) {
const auto config_path = write_config(
root, "resample_cache_invalid.json",
std::string(R"JSON({"resample_kernel_cache_mb": )JSON") + value + "," + kMinimalModel + "}");
bool rejected = false;
try {
(void) minitts::server::load_server_config(config_path);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("resample_kernel_cache_mb") != std::string::npos;
}
require(rejected, "invalid resample_kernel_cache_mb is rejected: " + value);
}
}
void test_model_run_overrun_predicate() {
using minitts::server::model_run_has_overrun;
require(!model_run_has_overrun(0, 10'000'000, 1000), "an idle model never counts as overrun");
require(!model_run_has_overrun(1000, 1500, 1000), "a run inside the timeout waits normally");
require(!model_run_has_overrun(1000, 2000, 1000), "a run exactly at the timeout is not yet overrun");
require(model_run_has_overrun(1000, 2001, 1000), "a run past the timeout fails fast");
require(!model_run_has_overrun(1000, 10'000'000, 0), "timeout 0 disables the guard");
require(!model_run_has_overrun(1000, 10'000'000, -1), "a non-positive timeout disables the guard");
}
// Mirror of the estimator's formula: weights * 1.5 plus the fixed floor, so the
// tests assert absolute values rather than "greater than".
size_t expected_estimate(size_t weights) {
constexpr double kRuntimeOverheadFactor = 1.5;
constexpr size_t kFixedOverhead = 128ull * 1024 * 1024;
return static_cast<size_t>(static_cast<double>(weights) * kRuntimeOverheadFactor) + kFixedOverhead;
}
// The engines a plan declares are priced by the loader that owns the family, so a build that did not
// compile that family has no loader to ask and the guard has nothing to price: CI's sanitizer builds
// link one family (`builtin_audio_utils`), which reports no engines by contract. The cases below
// therefore state the priced expectation when the family is in this registry, and the empty-plan
// contract when it is not -- which is the same contract, seen from the other side.
bool family_compiled(const std::string & family) {
return engine::runtime::make_default_registry().supports_family(family);
}
void write_file(const std::filesystem::path & path, size_t bytes) {
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out << std::string(bytes, 'x');
if (!out) {
throw std::runtime_error("failed to write test file: " + path.string());
}
}
void test_model_memory_estimator() {
using minitts::server::estimate_model_memory_bytes;
using minitts::server::ServerModelConfig;
const auto root = make_temp_root();
// A single-file model contributes that file.
{
const auto path = root / "single.gguf";
write_file(path, 1000);
ServerModelConfig model;
model.path = path;
const auto estimate = estimate_model_memory_bytes(model);
require(estimate.has_value(), "single-file model has a determinate footprint");
require(*estimate == expected_estimate(1000), "single-file estimate sums the file");
}
// A directory with exactly one GGUF contributes that file.
{
const auto dir = root / "sole";
std::filesystem::create_directories(dir);
write_file(dir / "variant.gguf", 2000);
ServerModelConfig model;
model.path = dir;
const auto estimate = estimate_model_memory_bytes(model);
require(estimate.has_value(), "sole-GGUF directory has a determinate footprint");
require(*estimate == expected_estimate(2000), "the sole GGUF is selected");
}
// model.gguf disambiguates a multi-GGUF directory, ignoring other variants.
{
const auto dir = root / "named";
std::filesystem::create_directories(dir);
write_file(dir / "a.gguf", 3000);
write_file(dir / "model.gguf", 4000);
ServerModelConfig model;
model.path = dir;
const auto estimate = estimate_model_memory_bytes(model);
require(estimate.has_value(), "model.gguf makes the directory determinate");
require(*estimate == expected_estimate(4000), "model.gguf wins over the other variants");
}
// Several GGUFs and no model.gguf: the loader rejects the spec-driven case
// with its own error, so the footprint is indeterminate and the guard skips.
{
const auto dir = root / "ambiguous";
std::filesystem::create_directories(dir);
write_file(dir / "a.gguf", 100);
write_file(dir / "b.gguf", 100);
ServerModelConfig model;
model.path = dir;
require(
!estimate_model_memory_bytes(model).has_value(),
"an ambiguous multi-GGUF directory has an indeterminate footprint");
}
// A directory with no GGUF is a safetensors/HF checkpoint: the tree is summed.
{
const auto dir = root / "tree";
std::filesystem::create_directories(dir / "sub");
write_file(dir / "model.safetensors", 5000);
write_file(dir / "sub" / "chunk.bin", 6000);
ServerModelConfig model;
model.path = dir;
const auto estimate = estimate_model_memory_bytes(model);
require(estimate.has_value(), "a no-GGUF directory has a determinate footprint");
require(*estimate == expected_estimate(11000), "a checkpoint tree is summed recursively");
}
// Relative auxiliary session files resolve against the model directory.
{
// Not named "aux": that is a reserved DOS device name and cannot be
// created on Windows.
const auto dir = root / "sidecar";
std::filesystem::create_directories(dir);
write_file(dir / "model.gguf", 7000);
write_file(dir / "head.bin", 8000);
ServerModelConfig model;
model.path = dir;
model.session_options["aux_path"] = "head.bin";
const auto estimate = estimate_model_memory_bytes(model);
require(estimate.has_value(), "an aux-resolved directory has a determinate footprint");
require(*estimate == expected_estimate(15000), "a relative aux path resolves against the model directory");
}
// An exec_plan engine is counted by name, through the family's loader: the option value is JSON,
// so the path scan sees nothing, and a name relative to the model directory is how a deployment
// spells an engine installed with the weights. The names here are not engine images and this
// gate has no NPU runtime, so the runtime cannot price them and they fall back to their file
// size -- at 1.0, not the runtime factor, because a compiled engine is resident at its own
// allocation and builds no graph.
{
const auto dir = root / "plan";
std::filesystem::create_directories(dir);
write_file(dir / "model.gguf", 7000);
write_file(dir / "flow_seq110.rknn", 900);
write_file(dir / "flow_seq200.rknn", 1100);
const auto elsewhere = root / "plan-abs.rknn";
write_file(elsewhere, 500);
ServerModelConfig model;
model.family = "cosyvoice3";
model.path = dir;
// generic_string(), not string(): on Windows the native separators are
// backslashes, which are invalid escapes inside the JSON plan.
model.session_options["cosyvoice3.exec_plan"] =
R"({"flow":{"impl":"rknn","artifacts":{"main":"flow_seq110.rknn,flow_seq200.rknn,)" +
elsewhere.generic_string() + R"("}}})";
const auto estimate = estimate_model_memory_bytes(model);
const auto priced = family_compiled("cosyvoice3") ? size_t{900 + 1100 + 500} : size_t{0};
require(estimate.has_value(), "an exec_plan model has a determinate footprint");
require(
*estimate == expected_estimate(7000) + priced,
"exec_plan engines resolve relative to the model directory, and an absolute path is used as given");
}
// A plan on a family that states no residency policy is priced by the sum of its engines, and
// the engines of a ladder are not multiplied by the runtime factor.
{
const auto dir = root / "plan-sum";
std::filesystem::create_directories(dir);
write_file(dir / "model.gguf", 7000);
for (const std::string rung : {"flow_seq250.rknn", "flow_seq400.rknn"}) {
write_file(dir / rung, 1); // resize_file needs the file to exist; the rest stays sparse
std::filesystem::resize_file(dir / rung, 3ull * 1024 * 1024 * 1024);
}
ServerModelConfig model;
model.family = "qwen3_tts";
model.path = dir;
model.session_options["qwen3_tts.exec_plan"] =
R"({"speech_decoder":{"impl":"rknn","artifacts":{"main":"flow_seq250.rknn","first":"flow_seq400.rknn"}}})";
const auto estimate = estimate_model_memory_bytes(model);
const auto priced =
family_compiled("qwen3_tts") ? 6ull * 1024 * 1024 * 1024 : size_t{0};
require(
estimate.has_value() && *estimate == expected_estimate(7000) + priced,
"a family without a residency policy is priced by the sum of its engines, at 1.0");
}
// The ceiling a family states for its engines caps the priced set: a ladder that evicts to stay
// inside an NPU aperture is not resident at the sum of its parts. The ceiling CosyVoice3 states is
// the aperture budget, so the knob that sets it (AUDIOCPP_RKNN_APERTURE_MIB, see rknn_flow.h) is
// how this gate exercises the model-local policy with file-size residency on this host.
{
const auto dir = root / "plan-capped";
std::filesystem::create_directories(dir);
write_file(dir / "model.gguf", 7000);
std::string declared;
for (const std::string rung : {"flow_seq110.rknn", "flow_seq200.rknn", "flow_seq250.rknn"}) {
write_file(dir / rung, 1);
std::filesystem::resize_file(dir / rung, 1024ull * 1024 * 1024);
if (!declared.empty()) {
declared += ',';
}
declared += rung;
}
ServerModelConfig model;
model.family = "cosyvoice3";
model.path = dir;
model.session_options["cosyvoice3.exec_plan"] =
R"({"flow":{"impl":"rknn","artifacts":{"main":")" + declared + R"("}}})";
const auto uncapped = estimate_model_memory_bytes(model);
set_env("AUDIOCPP_RKNN_APERTURE_MIB", "1024");
const auto capped = estimate_model_memory_bytes(model);
set_env("AUDIOCPP_RKNN_APERTURE_MIB", nullptr);
const auto sum = family_compiled("cosyvoice3") ? 3ull * 1024 * 1024 * 1024 : size_t{0};
const auto ceiling = family_compiled("cosyvoice3") ? 1024ull * 1024 * 1024 : size_t{0};
require(
uncapped.has_value() && *uncapped == expected_estimate(7000) + sum,
"a ladder under its ceiling is priced by the sum of its engines");
require(
capped.has_value() && *capped == expected_estimate(7000) + ceiling,
"a family's residency ceiling caps the engines it prices");
// The eviction rule the ceiling describes admits one engine wider than the ceiling by
// evicting the rest, so the cap is a sharing budget, not a hard ceiling: the priced set
// floors at the largest single engine. A 3 GiB rung next to a 1 GiB one: with the
// ladder's own default budget the 4 GiB sum caps to that budget, and with a 1 GiB
// budget it caps to 1 GiB -- in both cases the floor lifts the estimate back to the
// 3 GiB rung that the eviction rule can hold alone.
const auto wide_dir = root / "plan-capped-wide";
std::filesystem::create_directories(wide_dir);
write_file(wide_dir / "model.gguf", 7000);
const auto small_rung = wide_dir / "flow_seq110.rknn";
const auto big_rung = wide_dir / "flow_seq1000.rknn";
write_file(small_rung, 1);
std::filesystem::resize_file(small_rung, 1024ull * 1024 * 1024);
write_file(big_rung, 1);
std::filesystem::resize_file(big_rung, 3ull * 1024 * 1024 * 1024);
ServerModelConfig wide;
wide.family = "cosyvoice3";
wide.path = wide_dir;
wide.session_options["cosyvoice3.exec_plan"] =
R"({"flow":{"impl":"rknn","artifacts":{"main":"flow_seq110.rknn,flow_seq1000.rknn"}}})";
const auto wide_default = estimate_model_memory_bytes(wide);
set_env("AUDIOCPP_RKNN_APERTURE_MIB", "1024");
const auto wide_capped = estimate_model_memory_bytes(wide);
set_env("AUDIOCPP_RKNN_APERTURE_MIB", nullptr);
const auto default_budget = family_compiled("cosyvoice3")
? 3900ull * 1024 * 1024
: size_t{0};
const auto wide_floor = family_compiled("cosyvoice3") ? 3ull * 1024 * 1024 * 1024 : size_t{0};
require(
wide_default.has_value() && *wide_default == expected_estimate(7000) + default_budget,
"the sum of the set meets the ladder's own default budget when it fits above the floor");
require(
wide_capped.has_value() && *wide_capped == expected_estimate(7000) + wide_floor,
"a ceiling floors at the largest single engine the eviction rule admits alone");
}
// An engine inside a summed checkpoint tree is priced by the plan, once: the plan is applied
// before the scans, so the engine is counted at the engine price (the driver's residency, here
// the file size) and the tree scan skips the same path -- it is not repriced at the runtime
// factor the weights carry.
{
const auto dir = root / "plan-tree";
std::filesystem::create_directories(dir);
write_file(dir / "model.safetensors", 5000);
write_file(dir / "vocoder.rknn", 600);
ServerModelConfig model;
model.family = "piper_tts";
model.path = dir;
model.session_options["piper_tts.exec_plan"] =
R"({"decoder":{"impl":"rknn","artifacts":{"main":"vocoder.rknn","first":"absent.rknn"}}})";
const auto estimate = estimate_model_memory_bytes(model);
const auto priced = family_compiled("piper_tts")
? expected_estimate(5000) + 600
: expected_estimate(5600);
require(
estimate.has_value() && *estimate == priced,
"an engine declared by the plan is priced once, as an engine, and the tree scan skips it");
}
// A custom (non-spec-backed) loader answers the same hook: Qwen3-TTS's loader reads its own
// plan, so its declared speech decoder reaches the guard the same way.
{
const auto dir = root / "plan-qwen3";
std::filesystem::create_directories(dir);
write_file(dir / "model.gguf", 7000);
const auto vocoder = dir / "vocoder.rknn";
write_file(vocoder, 1);
std::filesystem::resize_file(vocoder, 1024ull * 1024 * 1024);
ServerModelConfig model;
model.family = "qwen3_tts";
model.path = dir;
model.session_options["qwen3_tts.exec_plan"] =
R"({"speech_decoder":{"impl":"rknn","artifacts":{"main":"vocoder.rknn"}}})";
const auto estimate = estimate_model_memory_bytes(model);
const auto priced = family_compiled("qwen3_tts") ? 1024ull * 1024 * 1024 : size_t{0};
require(
estimate.has_value() && *estimate == expected_estimate(7000) + priced,
"a custom loader reports its plan's engines through the same hook");
}
// Every RKNN family explicitly owns its resource reporting. Unselected components and
// artifacts on a ggml implementation do not become resident RKNN engines.
for (const auto & family_component : {
std::pair<std::string, std::string>{"piper_tts", "decoder"},
{"inflect_v2", "decoder"}, {"kokoro_tts", "generator"},
{"qwen3_tts", "speech_decoder"}, {"cosyvoice3", "flow"}}) {
const auto & family = family_component.first;
const auto & component = family_component.second;
const auto dir = root / ("selected-" + family);
std::filesystem::create_directories(dir);
write_file(dir / "model.gguf", 7000);
write_file(dir / "engine.rknn", 600);
ServerModelConfig model;
model.family = family;
model.path = dir;
const std::string artifacts = R"(,"artifacts":{"main":"engine.rknn"}}})";
model.session_options[family + ".exec_plan"] =
"{\"" + component + R"(":{"impl":"rknn")" + artifacts;
const auto estimate = estimate_model_memory_bytes(model);
require(estimate && *estimate == expected_estimate(7000) + (family_compiled(family) ? 600 : 0),
family + " reports its selected engine");
model.session_options[family + ".exec_plan"] =
"{\"" + component + R"(":{"impl":"ggml")" + artifacts;
const auto ggml_estimate = estimate_model_memory_bytes(model);
require(ggml_estimate && *ggml_estimate == expected_estimate(7000),
family + " does not price artifacts for an unselected RKNN implementation");
model.session_options.clear();
require(estimate_model_memory_bytes(model) == ggml_estimate, family + " keeps its default estimate");
std::vector<std::string> invalid_plans{
"{not json",
"{\"" + component + R"(":{"impl":"rknn"}})",
"{\"" + component + R"(":{"impl":"rknn","artifacts":{}}})",
"{\"" + component + R"(":{"impl":"rknn","artifacts":{"main":false}}})"};
if (family == "qwen3_tts") {
invalid_plans.push_back(
R"({"speech_decoder":{"impl":"rknn","artifacts":{"main":"engine.rknn","first":5}}})");
}
for (const auto & invalid : invalid_plans) {
model.session_options[family + ".exec_plan"] = invalid;
require(estimate_model_memory_bytes(model) == ggml_estimate,
family + " defers invalid-plan errors during estimation");
bool rejected = false;
try {
(void)engine::runtime::parse_exec_plan(
model.session_options, family + ".exec_plan",
{{component, "ggml", {{"ggml", {}, {}, {}}, {"rknn", {}, {"main"}, {"first"}}}}});
} catch (const std::runtime_error &) {
rejected = true;
}
require(rejected, family + " plan validation still rejects the invalid input");
}
}
}
void test_model_slots() {
const auto root = make_temp_root();
for (const std::string value : {"1", "2", "16", "0", "-1", "17", "null", "true", "\"2\"", "1.5", "4294967297"}) {
const auto text = std::string(R"({"models":[{"id":"higgs","family":"higgs_audio_tts","path":"model.gguf","slots":)")
+ value + "}]}";
const auto path = write_config(root, "slots.json", text);
bool legacy_rejected = false;
try { (void)minitts::server::load_server_config(path); }
catch (const std::runtime_error & error) {
legacy_rejected = std::string(error.what()).find("slots requires --parallel-jobs") != std::string::npos;
}
require(legacy_rejected, "legacy configuration accepted an explicit slots field");
bool rejected = false;
try {
const auto config = minitts::server::load_server_config(path, true);
require(config.models.front().slots == std::stoi(value), "slots value was not preserved");
} catch (const std::runtime_error &) { rejected = true; }
require(rejected == (value != "1" && value != "2" && value != "16"), "parallel capacity validation differs");
}
const auto path = write_config(root, "default-slots.json",
R"({"models":[{"id":"higgs","family":"higgs_audio_tts","path":"model.gguf"}]})");
for (bool enabled : {false, true}) {
const auto config = minitts::server::load_server_config(path, enabled);
require(config.models.front().slots == 1, "default capacity must remain one");
}
}
void test_per_model_devices() {
const auto root = make_temp_root();
const auto path = write_config(root, "devices.json", R"JSON({
"device": 2,
"models": [
{"id":"inherited","family":"kokoro_tts","path":"a.gguf"},
{"id":"overridden","family":"kokoro_tts","path":"b.gguf","device":1}
]
})JSON");
const auto config = minitts::server::load_server_config(path);
require(config.device == 2, "server device is preserved");
require(!config.models[0].device.has_value(), "model without device inherits at runtime");
require(config.models[1].device == 1, "per-model device is parsed");
const auto bad = write_config(root, "negative-device.json", R"JSON({
"models":[{"id":"bad","family":"kokoro_tts","path":"m.gguf","device":-1}]
})JSON");
bool rejected = false;
try { (void)minitts::server::load_server_config(bad); }
catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("device for model bad") != std::string::npos;
}
require(rejected, "negative per-model device is rejected with the model id");
}
} // namespace
int main() {
try {
{
const auto root = make_temp_root();
const auto path = write_config(root, "implementation.json", R"({"backend":"hip","backend_impl":"gfx1151_v1","ui_management":true,"models":[]})");
const auto config = minitts::server::load_server_config(path);
require(config.backend == engine::core::BackendType::Hip, "backend remains HIP");
require(config.backend_impl == "gfx1151_v1", "backend implementation selection");
}
test_per_model_devices();
test_model_slots();
test_inline_default_and_named_presets();
test_model_spec_override_selection();
test_builtin_utility_model_paths();
test_default_preset_name();
test_bad_preset_options_are_rejected();
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();
test_max_request_body_defaults_and_overrides();
test_negative_max_request_body_is_rejected();
test_unsafe_numeric_max_request_body_is_rejected();
test_negative_busy_timeout_is_rejected();
test_max_loaded_models_defaults_and_overrides();
test_negative_max_loaded_models_is_rejected();
test_idle_unload_ms_defaults_and_overrides();
test_negative_idle_unload_ms_is_rejected();
test_min_free_memory_mb_defaults_and_overrides();
test_negative_min_free_memory_mb_is_rejected();
test_resample_kernel_cache_mb_defaults_and_overrides();
test_invalid_resample_kernel_cache_mb_is_rejected();
test_per_model_busy_timeout();
test_negative_per_model_busy_timeout_is_rejected();
test_ui_configuration();
test_configured_model_fields_are_inherited();
test_empty_models_require_ui_management();
test_request_timeout_is_clamped_to_policy();
test_model_run_overrun_predicate();
test_model_memory_estimator();
} catch (const std::exception & error) {
std::cerr << error.what() << '\n';
return 1;
}
std::cout << "server_config_test passed\n";
return 0;
}