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
1191 lines
52 KiB
C++
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;
|
|
}
|