diff --git a/backend/cpp/audio-cpp/CMakeLists.txt b/backend/cpp/audio-cpp/CMakeLists.txt index 3b5f36278..2fe8c25d3 100644 --- a/backend/cpp/audio-cpp/CMakeLists.txt +++ b/backend/cpp/audio-cpp/CMakeLists.txt @@ -86,6 +86,7 @@ add_subdirectory("${AUDIO_CPP_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/audio-cpp" EXCL add_executable(${TARGET} grpc-server.cpp + model_options.cpp ) target_include_directories(${TARGET} PRIVATE diff --git a/backend/cpp/audio-cpp/model_options.cpp b/backend/cpp/audio-cpp/model_options.cpp new file mode 100644 index 000000000..43b3ffd7a --- /dev/null +++ b/backend/cpp/audio-cpp/model_options.cpp @@ -0,0 +1,124 @@ +#include "model_options.h" + +#include +#include + +namespace audiocpp_backend { +namespace { + +std::string trim(const std::string &value) { + size_t begin = 0; + while (begin < value.size() && + std::isspace(static_cast(value[begin])) != 0) { + ++begin; + } + size_t end = value.size(); + while (end > begin && + std::isspace(static_cast(value[end - 1])) != 0) { + --end; + } + return value.substr(begin, end - begin); +} + +// Parses a non-negative integer. Returns false on anything else, including +// empty strings, signs, and trailing garbage. +bool parse_non_negative_int(const std::string &value, int &out) { + if (value.empty()) { + return false; + } + for (const char ch : value) { + if (std::isdigit(static_cast(ch)) == 0) { + return false; + } + } + out = std::atoi(value.c_str()); + return true; +} + +bool starts_with(const std::string &value, const std::string &prefix) { + return value.size() >= prefix.size() && + value.compare(0, prefix.size(), prefix) == 0; +} + +} // namespace + +ParsedOptions parse_model_options(const std::vector &entries) { + ParsedOptions parsed; + + for (const auto &raw : entries) { + const std::string entry = trim(raw); + if (entry.empty()) { + continue; + } + + // Split on the FIRST colon: values are often paths that contain more. + const size_t sep = entry.find(':'); + if (sep == std::string::npos) { + parsed.error = "audio-cpp: option '" + entry + + "' is not in key:value form"; + return parsed; + } + + const std::string key = trim(entry.substr(0, sep)); + const std::string value = trim(entry.substr(sep + 1)); + + if (starts_with(key, "load.")) { + const std::string inner = key.substr(5); + if (inner.empty()) { + parsed.error = "audio-cpp: option '" + entry + + "' has an empty load option name"; + return parsed; + } + parsed.options.load_options[inner] = value; + continue; + } + if (starts_with(key, "session.")) { + const std::string inner = key.substr(8); + if (inner.empty()) { + parsed.error = "audio-cpp: option '" + entry + + "' has an empty session option name"; + return parsed; + } + parsed.options.session_options[inner] = value; + continue; + } + + if (key == "family") { + parsed.options.family = value; + } else if (key == "task") { + parsed.options.task = value; + } else if (key == "backend") { + parsed.options.backend = value; + } else if (key == "model_spec_override") { + parsed.options.model_spec_override = value; + } else if (key == "device") { + if (!parse_non_negative_int(value, parsed.options.device)) { + parsed.error = "audio-cpp: option 'device' needs a non-negative " + "integer, got '" + value + "'"; + return parsed; + } + } else if (key == "threads") { + if (!parse_non_negative_int(value, parsed.options.threads)) { + parsed.error = "audio-cpp: option 'threads' needs a non-negative " + "integer, got '" + value + "'"; + return parsed; + } + } else if (key == "busy_timeout_ms") { + if (!parse_non_negative_int(value, parsed.options.busy_timeout_ms)) { + parsed.error = "audio-cpp: option 'busy_timeout_ms' needs a " + "non-negative integer, got '" + value + "'"; + return parsed; + } + } else { + parsed.error = "audio-cpp: unknown option key '" + key + + "'. Known keys: family, task, backend, device, " + "threads, model_spec_override, busy_timeout_ms, " + "load., session."; + return parsed; + } + } + + return parsed; +} + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/model_options.h b/backend/cpp/audio-cpp/model_options.h new file mode 100644 index 000000000..c8d8a71b2 --- /dev/null +++ b/backend/cpp/audio-cpp/model_options.h @@ -0,0 +1,42 @@ +#pragma once + +// Parses the model YAML's `options:` list (ModelOptions.Options in +// backend.proto) into a struct. Standard library only: this unit is compiled +// and tested by backend/cpp/run-unit-tests.sh without an audio.cpp checkout. + +#include +#include +#include + +namespace audiocpp_backend { + +struct ModelOptions { + // audio.cpp model family. Empty means "derive from the GGUF's embedded + // audiocpp.model_spec.family key"; a non-GGUF path with an empty family is + // rejected at load time, not here. + std::string family; + // Pins the audio.cpp task, overriding RPC-based routing. Empty means route. + std::string task; + // ggml backend: cpu, cuda, vulkan, metal, best. + std::string backend = "cpu"; + int device = 0; + // 0 means "let the runtime decide". + int threads = 0; + std::string model_spec_override; + // 0 disables the run guard's fail-fast, restoring an unbounded wait. + int busy_timeout_ms = 0; + // `load.:` entries, prefix stripped. + std::map load_options; + // `session.:` entries, prefix stripped. + std::map session_options; +}; + +struct ParsedOptions { + ModelOptions options; + // Non-empty means the caller must fail the load with INVALID_ARGUMENT. + std::string error; +}; + +ParsedOptions parse_model_options(const std::vector &entries); + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/model_options_test.cpp b/backend/cpp/audio-cpp/model_options_test.cpp new file mode 100644 index 000000000..4c5ac4e7c --- /dev/null +++ b/backend/cpp/audio-cpp/model_options_test.cpp @@ -0,0 +1,123 @@ +// Unit tests for model_options. Standard library only, so +// backend/cpp/run-unit-tests.sh picks this up with no engine checkout. +// +// The harness compiles this file as a single translation unit with no other +// sources, so the implementation is included directly rather than linked. +// +// Build and run standalone: +// g++ -std=c++17 -I. model_options_test.cpp -o t && ./t + +#include "model_options.cpp" + +#include +#include +#include + +static int failures = 0; + +static void check(bool ok, const std::string &name) { + if (!ok) { + failures++; + fprintf(stderr, "FAIL: %s\n", name.c_str()); + } else { + fprintf(stderr, "ok: %s\n", name.c_str()); + } +} + +using audiocpp_backend::parse_model_options; + +static void test_defaults() { + auto r = parse_model_options({}); + check(r.error.empty(), "empty option list is not an error"); + check(r.options.family.empty(), "family defaults to empty"); + check(r.options.task.empty(), "task defaults to empty"); + check(r.options.backend == "cpu", "backend defaults to cpu"); + check(r.options.device == 0, "device defaults to 0"); + check(r.options.threads == 0, "threads defaults to 0"); + check(r.options.busy_timeout_ms == 0, "busy_timeout_ms defaults to 0"); + check(r.options.load_options.empty(), "load_options defaults empty"); + check(r.options.session_options.empty(), "session_options defaults empty"); +} + +static void test_scalar_options() { + auto r = parse_model_options({ + "family:qwen3_tts", + "task:tts", + "backend:cuda", + "device:1", + "threads:8", + "busy_timeout_ms:30000", + }); + check(r.error.empty(), "scalar options parse without error"); + check(r.options.family == "qwen3_tts", "family parsed"); + check(r.options.task == "tts", "task parsed"); + check(r.options.backend == "cuda", "backend parsed"); + check(r.options.device == 1, "device parsed"); + check(r.options.threads == 8, "threads parsed"); + check(r.options.busy_timeout_ms == 30000, "busy_timeout_ms parsed"); +} + +// Values containing colons must survive: split on the FIRST colon only. +static void test_value_containing_colon() { + auto r = parse_model_options({"model_spec_override:/models/a:b/spec.json"}); + check(r.error.empty(), "colon-bearing value is not an error"); + check(r.options.model_spec_override == "/models/a:b/spec.json", + "value keeps every colon after the first separator"); +} + +static void test_namespaced_options() { + auto r = parse_model_options({ + "load.weight_type:q8_0", + "session.miocodec.weight_type:f16", + "session.graph_capacity:tiered", + }); + check(r.error.empty(), "namespaced options parse without error"); + check(r.options.load_options.size() == 1, "one load option"); + check(r.options.load_options.at("weight_type") == "q8_0", "load prefix stripped"); + check(r.options.session_options.size() == 2, "two session options"); + check(r.options.session_options.at("miocodec.weight_type") == "f16", + "session prefix stripped, inner dots kept"); + check(r.options.session_options.at("graph_capacity") == "tiered", + "second session option parsed"); +} + +static void test_errors() { + check(!parse_model_options({"family"}).error.empty(), + "entry without a colon is rejected"); + check(!parse_model_options({"nonsense:1"}).error.empty(), + "unknown key is rejected"); + check(!parse_model_options({"device:abc"}).error.empty(), + "non-numeric device is rejected"); + check(!parse_model_options({"threads:-1"}).error.empty(), + "negative threads is rejected"); + check(!parse_model_options({"load.:x"}).error.empty(), + "empty load key is rejected"); + check(!parse_model_options({"session.:x"}).error.empty(), + "empty session key is rejected"); + + // The error text must name the offending entry so a user can fix their YAML. + const auto r = parse_model_options({"nonsense:1"}); + check(r.error.find("nonsense") != std::string::npos, + "error names the offending key"); +} + +static void test_blank_entries_ignored() { + auto r = parse_model_options({"", " ", "family:supertonic"}); + check(r.error.empty(), "blank entries are skipped, not rejected"); + check(r.options.family == "supertonic", "real entry still parsed"); +} + +int main() { + test_defaults(); + test_scalar_options(); + test_value_containing_colon(); + test_namespaced_options(); + test_errors(); + test_blank_entries_ignored(); + if (failures) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + fprintf(stderr, "all model_options checks passed\n"); + return 0; +}