mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 18:38:23 -04:00
The first UTF-8 fix closed the cumulative half only. Rule 2 discards a fragment the known text already starts with, and when that fragment is the LEAD BYTE of a new character it looks exactly like a repeat of an older character beginning with the same byte. It was discarded rather than held, its continuation bytes then arrived alone and began the next delta, and utf8_complete_prefix_length only ever inspected the trailing sequence, so a delta invalid at the FRONT went out whole. Through a real Go proto.Unmarshal the review's four-character repro gave 3 deltas, 2 unmarshal failures and a lost transcript. Reachable from the incremental families, not only from voxtral: nemotron_asr's decoder cuts at a byte offset and vibevoice_asr's common_prefix_size compares bytes, so both split characters. Measured over 30,000 randomized incremental traces, 53.28% of Japanese traces and 9.52% of French ones carried at least one delta the Go runtime refuses. Two changes. Rule 2 no longer judges a fragment that ends mid-character, so the lead byte is held instead of swallowed and the character survives intact; the cost is a few duplicated bytes in a shrinking cumulative report, which no pinned family produces. release() additionally drops leading orphan continuation bytes, so no delta can begin mid-character whatever the rules above it decide. Losing a byte keeps the stream alive; emitting one ends the RPC and takes the final_result with it. Post-fix all 60,000 traces produce zero unmarshal failures, and the cumulative streams plus both pure-ASCII incremental streams are byte-identical to the previous commit, so nothing changed for the families already working. The weight-dtype allow list moves to family_gate, where it is stdlib-only and pinned by a test rather than only by a comment. Two comment citations corrected. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
168 lines
8.1 KiB
C++
168 lines
8.1 KiB
C++
// 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 <cstdio>
|
|
#include <string>
|
|
|
|
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());
|
|
}
|
|
}
|
|
|
|
static void check_eq(const std::string &got, const std::string &want,
|
|
const std::string &name) {
|
|
check(got == want, name + " (got \"" + got + "\" want \"" + want + "\")");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
// Pins the weight-dtype allow list. Not a style preference: an entry here is a
|
|
// family that ABORTS THE PROCESS on the first request when handed the wrong
|
|
// dtype, so the model loads and then every request kills the backend with no
|
|
// status and no message.
|
|
//
|
|
// What this test can and cannot do, stated so the next reader does not expect
|
|
// more of it: it pins WHAT THE TABLE SAYS, so widening an entry is a deliberate
|
|
// act rather than a typo, and it pins that an unlisted family is unrestricted.
|
|
// It CANNOT pin the removal criterion, which is "upstream fixed it": knowing
|
|
// that needs the package downloaded and a synthesis run, so it stays a manual
|
|
// step documented at the table in family_gate.cpp.
|
|
static void test_weight_dtype_allow_list() {
|
|
// The entry that exists, and the exact reason it exists.
|
|
check(!weight_dtype_is_supported("supertonic", "f16"),
|
|
"supertonic refuses f16, the package that aborts the process");
|
|
check(!weight_dtype_is_supported("supertonic", "q8_0"),
|
|
"supertonic refuses q8_0, which upstream records as unsupported");
|
|
check(!weight_dtype_is_supported("supertonic", "bf16"),
|
|
"supertonic refuses bf16, which is untested rather than known good");
|
|
check(weight_dtype_is_supported("supertonic", "f32"),
|
|
"supertonic accepts f32, which is what the orig package stores");
|
|
check(weight_dtype_is_supported("supertonic", "i64"),
|
|
"supertonic accepts i64: the orig package carries 72 such tensors and "
|
|
"refusing them would refuse the artifact that works");
|
|
|
|
// Every other family is unrestricted, and must stay that way: this guard is
|
|
// for process death, not for quality.
|
|
check(weight_dtype_is_supported("nemotron_asr", "q8_0"),
|
|
"an unlisted family is not restricted");
|
|
check(weight_dtype_is_supported("citrinet_asr", "f16"),
|
|
"an unlisted family is not restricted by another family's entry");
|
|
check(weight_dtype_is_supported("", "anything"),
|
|
"an empty family name is not restricted");
|
|
|
|
// The message the operator reads has to name the remedy, so the refusal is
|
|
// actionable rather than only correct.
|
|
check_eq(supported_weight_dtypes("supertonic"), "f32, i64",
|
|
"the refusal can name what to look for");
|
|
check_eq(supported_weight_dtypes("nemotron_asr"), "",
|
|
"an unlisted family reports no restriction, which is what makes "
|
|
"the caller skip the whole check");
|
|
}
|
|
|
|
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();
|
|
test_weight_dtype_allow_list();
|
|
if (failures) {
|
|
fprintf(stderr, "%d check(s) failed\n", failures);
|
|
return 1;
|
|
}
|
|
fprintf(stderr, "all family_gate checks passed\n");
|
|
return 0;
|
|
}
|