mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 10:28:43 -04:00
The one bidirectional stream this backend serves. The client sends a
TranscriptLiveConfig, then TranscriptLiveAudio frames; the server acknowledges
with ready, emits deltas as the audio arrives, and sends final_result once the
read side closes. There is no offline fallback: live transcription has to
consume audio incrementally, so a family with no streaming ASR is refused
rather than served a batch run, which is what this RPC's Streaming-only
mode_candidates list already says.
The driver is a new sibling of run_streaming_audio, run_streaming_live, because
the audio does not exist yet: instead of slicing a buffer it pulls frames from
the caller until the read side closes. It installs the same ScopedStreamSink in
the same order, which is not optional, since nemotron_asr returns a bare event
from process_audio_chunk and reports every partial through the sink from inside
finalize(). It buffers the wire's frames up to the family's own preferred window
rather than feeding whatever size the client's audio callback produced, and it
does not call finish_stream at all when no audio arrived, because nemotron_asr
throws "finalize requires streamed audio" and an empty transcript is the
truthful answer to transcribing nothing.
Three things the handler had to get right and one it cannot:
- The audio contract. A live request carries no samples, but nemotron_asr's
streaming prepare() throws without an audio contract, and
build_preparation_request derives it from TaskRequest::audio_input, so that
field is an EMPTY buffer holding only the rate and the channel count.
- 16 kHz or a refusal. The families express their spans in their own 16 kHz
feature domain whatever the input was, and live frames cannot be resampled
on the way in the way a file can, so an 8 kHz session would return
timestamps 2x off with a 200. core/backend hardcodes 16000 anyway.
- A mid-stream Config is refused. backend.proto calls it a decoder reset, but
deltas already on the wire cannot be retracted, so a reset would leave the
final text contradicting the transcript the client assembled. Ignoring the
message would hand a client that believes it reset the decoder a transcript
that silently continues the audio it thought it discarded.
- The stale-route identity check cannot run here: TranscriptLiveRequest
carries no ModelIdentity in either arm of its oneof, so snapshot_for does
not instantiate for it. snapshot_unchecked's comment now names that as a
second legitimate class of caller and says the fix is a proto change.
eou and eob stay false. They exist for cache-aware models that emit
end-of-utterance and end-of-backchannel tokens; audio.cpp's StreamEvent has no
equivalent signal, and a client uses eou to decide the speaker yielded the turn,
so a guess inferred from silence cuts people off mid-sentence.
The lane is held for the whole stream, which is as long as the user keeps
talking: the streaming session is stateful and cached, so a concurrent run would
interleave two callers' audio and corrupt both transcripts.
Verified against nemotron_asr over a real connection with a 14 s WAV in
512-sample frames: ready first, 59 incremental deltas with no repeated prefix,
concat(deltas) equal to final_result.text, word timestamps in nanoseconds, eou
and eob false. citrinet_asr answers UNIMPLEMENTED naming the family and listing
asr/offline. A config followed by a close returns an empty final_result rather
than hanging, and a first message that is not a config is INVALID_ARGUMENT. Two
concurrent streams both return the complete transcript.
Two cleanups on lines Task 12 touched, folded in. The DtypeAllowList terminator
is now asserted at compile time: the reported out-of-bounds read did not exist,
the single entry does terminate, but the loops have no other bound and any edit
that widened an entry would walk off the end. And the dtype guard now
short-circuits on "is there a table entry" through a new predicate rather than
on the emptiness of the description string, which would have skipped the check
on an entry with an empty allow list, i.e. on precisely the entry that refuses
every dtype.
Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
180 lines
8.8 KiB
C++
180 lines
8.8 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");
|
|
|
|
// What the caller actually decides on, and it is a DIFFERENT question from
|
|
// "is the description empty": an entry with an empty allow list would
|
|
// describe itself as "" while refusing every dtype, so a caller that skipped
|
|
// the file read on the empty string would skip the check on the one entry
|
|
// that refuses everything.
|
|
check(family_has_weight_dtype_allow_list("supertonic"),
|
|
"a listed family has an allow list");
|
|
check(!family_has_weight_dtype_allow_list("nemotron_asr"),
|
|
"an unlisted family has none, which is what lets the caller skip "
|
|
"opening the file at all");
|
|
check(!family_has_weight_dtype_allow_list(""),
|
|
"an empty family name has no allow list");
|
|
}
|
|
|
|
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;
|
|
}
|