mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
backend(audio-cpp): parse namespaced model options
Splits option entries on the first colon so path values survive, and routes load./session. prefixes to the upstream load and session option maps. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
localai-org-maint-bot
parent
800bac1a6f
commit
7f78bbe752
@@ -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
|
||||
|
||||
124
backend/cpp/audio-cpp/model_options.cpp
Normal file
124
backend/cpp/audio-cpp/model_options.cpp
Normal file
@@ -0,0 +1,124 @@
|
||||
#include "model_options.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace audiocpp_backend {
|
||||
namespace {
|
||||
|
||||
std::string trim(const std::string &value) {
|
||||
size_t begin = 0;
|
||||
while (begin < value.size() &&
|
||||
std::isspace(static_cast<unsigned char>(value[begin])) != 0) {
|
||||
++begin;
|
||||
}
|
||||
size_t end = value.size();
|
||||
while (end > begin &&
|
||||
std::isspace(static_cast<unsigned char>(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<unsigned char>(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<std::string> &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.<key>, session.<key>";
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
} // namespace audiocpp_backend
|
||||
42
backend/cpp/audio-cpp/model_options.h
Normal file
42
backend/cpp/audio-cpp/model_options.h
Normal file
@@ -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 <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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.<key>:<value>` entries, prefix stripped.
|
||||
std::map<std::string, std::string> load_options;
|
||||
// `session.<key>:<value>` entries, prefix stripped.
|
||||
std::map<std::string, std::string> 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<std::string> &entries);
|
||||
|
||||
} // namespace audiocpp_backend
|
||||
123
backend/cpp/audio-cpp/model_options_test.cpp
Normal file
123
backend/cpp/audio-cpp/model_options_test.cpp
Normal file
@@ -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 <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user