diff --git a/backend/cpp/audio-cpp/family_gate.cpp b/backend/cpp/audio-cpp/family_gate.cpp new file mode 100644 index 000000000..a885677fe --- /dev/null +++ b/backend/cpp/audio-cpp/family_gate.cpp @@ -0,0 +1,60 @@ +#include "family_gate.h" + +#include + +namespace audiocpp_backend { +namespace { + +bool ends_with_ci(const std::string &value, const std::string &suffix) { + if (value.size() <= suffix.size()) { + return false; // a bare ".gguf" is an extension, not a model file + } + const size_t offset = value.size() - suffix.size(); + for (size_t i = 0; i < suffix.size(); ++i) { + const auto lhs = static_cast(value[offset + i]); + const auto rhs = static_cast(suffix[i]); + if (std::tolower(lhs) != std::tolower(rhs)) { + return false; + } + } + return true; +} + +} // namespace + +bool path_looks_like_gguf(const std::string &path) { + return ends_with_ci(path, ".gguf"); +} + +FamilyDecision decide_family(bool path_is_gguf, const std::string &embedded_family, + const std::string &configured_family) { + FamilyDecision decision; + + if (!configured_family.empty()) { + decision.ok = true; + decision.family = configured_family; + return decision; + } + + if (path_is_gguf) { + if (!embedded_family.empty()) { + decision.ok = true; + decision.family = embedded_family; + return decision; + } + decision.error = + "audio-cpp: this GGUF carries no 'audiocpp.model_spec.family' " + "metadata key, so it is not an audio.cpp model. Convert it with " + "audiocpp_gguf, or name the family explicitly with the model option " + "'family:'"; + return decision; + } + + decision.error = + "audio-cpp: a model path that is not a standalone audio.cpp GGUF needs " + "an explicit 'family:' model option, because the audio.cpp family " + "cannot be inferred from a safetensors or package directory"; + return decision; +} + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/family_gate.h b/backend/cpp/audio-cpp/family_gate.h new file mode 100644 index 000000000..cffaeb3d0 --- /dev/null +++ b/backend/cpp/audio-cpp/family_gate.h @@ -0,0 +1,35 @@ +#pragma once + +// Decides which audio.cpp family a model path belongs to, and refuses paths +// this backend must not claim. Standard library only. +// +// This is the guard against issue #9287. A model config with no explicit +// backend makes LocalAI probe every installed backend and bind to the first +// Load that succeeds, so accepting an arbitrary GGUF here would capture +// unrelated LLMs. audio.cpp GGUFs carry an audiocpp.model_spec.family metadata +// key; llama.cpp GGUFs do not. + +#include + +namespace audiocpp_backend { + +// True when the path ends in ".gguf", case insensitively, and has a stem. +bool path_looks_like_gguf(const std::string &path); + +struct FamilyDecision { + bool ok = false; + std::string family; + // Set when ok is false. Suitable verbatim as an INVALID_ARGUMENT message. + std::string error; +}; + +// Precedence: +// 1. an explicit `family:` option, so a user can override wrong metadata; +// 2. for a GGUF, the family embedded in audiocpp.model_spec.family; +// 3. otherwise refuse. +// A directory path never consults embedded metadata: there is no single GGUF +// to read it from. +FamilyDecision decide_family(bool path_is_gguf, const std::string &embedded_family, + const std::string &configured_family); + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/family_gate_test.cpp b/backend/cpp/audio-cpp/family_gate_test.cpp new file mode 100644 index 000000000..e0b665cfb --- /dev/null +++ b/backend/cpp/audio-cpp/family_gate_test.cpp @@ -0,0 +1,118 @@ +// Unit tests for family_gate. Standard library only. The harness compiles this +// as a single translation unit, so the implementation is included directly. +// +// This unit is the guard against issue #9287: when a model config has no +// explicit backend, LocalAI probes every installed backend and binds to the +// first Load that succeeds. Accepting an arbitrary GGUF here would capture +// unrelated LLMs. + +#include "family_gate.cpp" + +#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 namespace audiocpp_backend; + +static void test_gguf_suffix_detection() { + check(path_looks_like_gguf("/models/chatterbox-q8_0.gguf"), "plain .gguf"); + check(path_looks_like_gguf("/models/CHATTERBOX.GGUF"), "uppercase .GGUF"); + check(path_looks_like_gguf("/models/x.GgUf"), "mixed case .GgUf"); + check(path_looks_like_gguf("a.gguf"), "a one character stem is still a stem"); + check(!path_looks_like_gguf("/models/chatterbox"), "extensionless directory"); + check(!path_looks_like_gguf("/models/model.safetensors"), "safetensors"); + check(!path_looks_like_gguf("/models/gguf"), "a name that is merely 'gguf'"); + check(!path_looks_like_gguf("/models/GGUF"), "an uppercase name that is merely 'GGUF'"); + check(!path_looks_like_gguf(""), "empty path"); + check(!path_looks_like_gguf(".gguf"), "a bare extension is not a model file"); + // The suffix has to be at the end. A prefix or infix match would let + // ".gguf.tmp" download artefacts and ".ggufx" siblings through. + check(!path_looks_like_gguf("/models/model.gguf.tmp"), ".gguf in the middle"); + check(!path_looks_like_gguf("/models/model.ggufx"), "a longer extension"); + // Every character of the suffix has to match, including the last one. + check(!path_looks_like_gguf("/models/model.ggug"), "a near miss in the final character"); + check(!path_looks_like_gguf("/models/model_gguf"), "a near miss in the first character"); + check(!path_looks_like_gguf("/models/.gguf-notes"), "a leading .gguf"); +} + +static void test_explicit_family_always_wins() { + // Explicit configuration beats metadata, so a user can force a family when + // upstream metadata is wrong or absent. + const auto gguf = decide_family(true, "chatterbox", "omnivoice"); + check(gguf.ok && gguf.family == "omnivoice", "explicit family overrides GGUF metadata"); + check(gguf.error.empty(), "an accepted decision carries no error text"); + + const auto dir = decide_family(false, "", "qwen3_tts"); + check(dir.ok && dir.family == "qwen3_tts", "explicit family satisfies a directory path"); + + // A GGUF with no embedded spec is still loadable when the user names the + // family: the option is an override, not a tie-break that needs metadata to + // break against. + const auto bare = decide_family(true, "", "supertonic"); + check(bare.ok && bare.family == "supertonic", + "explicit family rescues a GGUF that carries no spec"); +} + +static void test_gguf_metadata_supplies_the_family() { + const auto d = decide_family(true, "nemotron_asr", ""); + check(d.ok, "an audio.cpp GGUF loads with no family option"); + check(d.family == "nemotron_asr", "family comes from the embedded spec"); + check(d.error.empty(), "an accepted GGUF carries no error text"); +} + +// THE GATE. A llama.cpp GGUF has no audiocpp.model_spec.family key. +static void test_foreign_gguf_is_refused() { + const auto d = decide_family(true, "", ""); + check(!d.ok, "a GGUF with no audio.cpp spec is refused"); + check(d.family.empty(), "no family is guessed"); + check(!d.error.empty(), "a refusal always says why"); + check(d.error.find("audiocpp.model_spec.family") != std::string::npos, + "error names the missing metadata key so the cause is diagnosable"); + check(d.error.find("family:") != std::string::npos, + "error names the option that would override it"); +} + +static void test_directory_without_family_is_refused() { + const auto d = decide_family(false, "", ""); + check(!d.ok, "a non-GGUF path with no family option is refused"); + check(d.family.empty(), "a refused directory guesses no family"); + check(d.error.find("family:") != std::string::npos, + "error names the required option"); +} + +// A directory path never consults embedded metadata, because there is no single +// GGUF to read it from. +static void test_directory_ignores_embedded_family() { + const auto d = decide_family(false, "chatterbox", ""); + check(!d.ok, "a directory is refused even when an embedded family is supplied"); + check(d.family.empty(), "a refused directory does not adopt the embedded family"); + // If the GGUF branch ever leaked into the directory branch this message + // would start blaming a metadata key that a directory has no place to carry. + check(d.error.find("audiocpp.model_spec.family") == std::string::npos, + "a directory refusal does not blame GGUF metadata it could not have"); +} + +int main() { + test_gguf_suffix_detection(); + test_explicit_family_always_wins(); + test_gguf_metadata_supplies_the_family(); + test_foreign_gguf_is_refused(); + test_directory_without_family_is_refused(); + test_directory_ignores_embedded_family(); + if (failures) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + fprintf(stderr, "all family_gate checks passed\n"); + return 0; +}