Files
LocalAI/backend/cpp/audio-cpp/loaded_model.h
Ettore Di Giacinto ae059ab897 backend(audio-cpp): enforce ModelIdentity on VAD and Diarize
audio-cpp was the only C++ backend without the model-identity guard, and no
later task in the plan added it. pkg/grpc/server.go enforces checkModelIdentity
on exactly these two RPCs, for the reason #10952 records: in distributed mode a
worker can recycle a stopped backend's gRPC port for another model's backend,
and the controller's liveness-only probe cannot tell a stale cached route from a
live one. Without this guard a stale route gets a different model's VAD or
diarization answer back with a 200.

The loaded identity lives on LoadedModel rather than in a separate global, which
is where this differs from llama-cpp. A handler holding the model through
snapshot() then necessarily judges against the identity that model was loaded
with, and a concurrent reload cannot swap one without the other. The refusal is
NOT_FOUND carrying the verbatim grpcerrors.ModelMismatchSentinel substring.

session_for and run_offline now take a const LaneEntry & proof-of-holding
parameter. The rule that both must run under the inference lane was prose, which
is exactly how the plan came to specify the inverted order; it is now a compile
error. Restoring the inverted order fails to build rather than racing on an
unsynchronised session map with a mutating prepare().

Diarize's speaker-hint comment claimed the dropped hints were "not a silent
failure". From the caller's side that is what they are, and backend.proto
documents num_speakers as forcing, so the comment now says plainly that the
forwarding is dead for sortformer and that the family which lands must either
honour num_speakers or refuse it. read_audio_file inspects the error_code from
exists(), so an unsearchable parent directory no longer reports as a missing
file. The VAD handler records the stimulus that actually works, since silero
correctly ignores synthetic tones and the next task would otherwise rediscover
that.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-29 19:03:32 +00:00

214 lines
10 KiB
C++

#pragma once
// Owns one audio.cpp model for the life of the process, plus a lazily created
// session per (task, mode) so the same model answers both TTS and TTSStream.
// This is the only unit that converts between the stdlib-only mirror types and
// engine::runtime types.
#include "capability_routing.h"
#include "inference_lane.h"
#include "model_options.h"
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/registry.h"
#include "engine/framework/runtime/session.h"
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace audiocpp_backend {
// User-fixable configuration problem. grpc-server maps this to INVALID_ARGUMENT.
class ConfigError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// The family cannot serve the requested RPC. Maps to UNIMPLEMENTED.
class CapabilityError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
engine::runtime::VoiceTaskKind to_engine_task(Task task);
engine::runtime::RunMode to_engine_mode(Mode mode);
Task from_engine_task(engine::runtime::VoiceTaskKind kind);
Mode from_engine_mode(engine::runtime::RunMode mode);
Capabilities to_capabilities(const std::string &family,
const engine::runtime::CapabilitySet &set);
// Reads audiocpp.model_spec.family from a GGUF. Returns an empty string when
// the file is not a GGUF, carries no audio.cpp spec, or cannot be read. Never
// throws: an unreadable file is the load gate's problem, not a crash.
std::string read_gguf_family(const std::string &path);
// Builds the absolute model path from LocalAI's (ModelPath, ModelFile, Model)
// triple. A model file of the form "bundled:<name>" resolves to
// <executable dir>/assets/<name>, which is where package.sh puts upstream's
// bundled silero_vad and marblenet_vad assets.
std::string resolve_model_path(const std::string &model_path_dir,
const std::string &model_file,
const std::string &model_name);
class LoadedModel {
public:
struct Session {
Task task = Task::Tts;
Mode mode = Mode::Offline;
// Exactly one of these is non-null, matching the resolved mode.
engine::runtime::IOfflineVoiceTaskSession *offline = nullptr;
engine::runtime::IStreamingVoiceTaskSession *streaming = nullptr;
};
// Throws ConfigError when the path does not exist, the family cannot be
// determined, or the registry rejects the family.
//
// `model_identity` is ModelOptions.Model verbatim: the UNTRANSLATED
// controller-side name. It is a constructor argument rather than a setter
// so identity and model are inseparable. llama-cpp keeps its equivalent in
// a separate global from the model, which leaves a window where a handler
// can read one without the other; here a handler that holds the model
// through snapshot() necessarily holds the identity it was loaded with.
LoadedModel(const std::string &resolved_path, const ModelOptions &options,
std::string model_identity);
LoadedModel(const LoadedModel &) = delete;
LoadedModel &operator=(const LoadedModel &) = delete;
const std::string &family() const noexcept { return capabilities_.family; }
// Empty when the controller predates ModelOptions.ModelIdentity, which the
// identity check reads as "skip". See check_model_identity in grpc-server.
const std::string &identity() const noexcept { return identity_; }
const std::string &variant() const noexcept { return variant_; }
const std::string &description() const noexcept { return description_; }
const std::vector<std::string> &languages() const noexcept { return languages_; }
const Capabilities &capabilities() const noexcept { return capabilities_; }
bool supports_timestamps() const noexcept { return supports_timestamps_; }
const engine::runtime::SessionOptions &session_options() const noexcept {
return session_options_;
}
// The model's `task:` option, empty when unset. Every handler must copy it
// into RequestShape::pinned_task before calling session_for: routing is
// otherwise derived from the RPC alone, and this is the option's only route
// from the load to the request that honours it.
const std::string &pinned_task() const noexcept { return pinned_task_; }
// Routes the RPC and returns the cached session, creating it on first use.
// Throws CapabilityError when this family cannot serve the RPC, and a plain
// runtime_error when it can but the session could not be built, which is an
// environment fault rather than a capability answer.
//
// The `lane` parameter is a PROOF OF HOLDING and is otherwise unused: it
// exists so the rule below is a compile error rather than prose. The
// session cache is an unsynchronised std::map and the sessions themselves
// are not reentrant, so this must only be called with the lane held; the
// lane admits one caller at a time, which is exactly the constraint the
// sessions impose. Pass the LaneEntry from acquire(). It is deliberately
// not a reference to `this`'s own lane member, because a caller can only
// produce a LaneEntry by having taken one.
//
// STATE CONTRACT, and it is the CALLER'S to honour. Sessions are cached per
// (task, mode), so a streaming session is normally the same warm object the
// previous stream used, carrying that stream's state. session_for hands it
// back as it is.
//
// Every streaming caller must therefore begin a stream with
//
// session.streaming->prepare(build_preparation_request(...));
// session.streaming->start_stream(request);
//
// in that order. start_stream's base implementation is a call to reset(),
// which is what clears the previous stream, and reset() is only legal after
// prepare(): silero_vad throws "session prepare() must be called before
// Silero VAD reset()" otherwise. That ordering constraint is also why
// session_for cannot do this for you. Skipping it does not raise an error,
// it silently continues the previous stream.
//
// Offline sessions need no such care: their interface has no reset and
// run() takes a whole request.
Session session_for(Rpc rpc, const RequestShape &shape,
const LaneEntry &lane);
// Takes the inference lane, or throws LaneUnavailable. Serializes runs
// against this model. `requested_timeout_ms` is a per-request wait hint
// where a value <= 0 means "use the model's configured ceiling"; a hint may
// only tighten that ceiling, never loosen it.
//
// LaneEntry is deliberately immovable, so bind the result to a named local
// in the scope the inference happens in:
//
// LaneEntry entry = model.acquire(request_hint_ms);
//
// which C++17 initializes in place. A handler that has to keep the lane
// beyond one scope, for instance in a member that outlives the call that
// took it, wants acquire_owned instead.
LaneEntry acquire(int requested_timeout_ms);
// Same lane, heap-allocated so it can be stored or handed on. Prefer
// acquire: this one adds a null state that the scoped form does not have.
std::unique_ptr<LaneEntry> acquire_owned(int requested_timeout_ms);
private:
// Keyed by the enum values so the map needs no custom comparator.
using SessionKey = std::pair<int, int>;
// The public constructor runs the load gate, then delegates here. The
// detour exists because lane_ has to be built from the family in the member
// initializer list, and the family is only known after the gate has run.
// Four parameters rather than three so it cannot be confused with the
// public constructor, whose third argument is also a std::string.
LoadedModel(const std::string &resolved_path, const ModelOptions &options,
std::string family, std::string model_identity);
// MEMBER ORDER IS LOAD-BEARING BELOW THIS LINE. Members are destroyed in
// reverse declaration order.
//
// lane_ is first so it is destroyed last: nothing that runs during teardown
// can then find a lane that has already gone.
InferenceLane lane_;
// registry_ before model_: the registry owns the loader that produced the
// model, and the model may hold loader-owned state.
engine::runtime::ModelRegistry registry_;
// model_ before sessions_, so sessions_ is destroyed FIRST and the model
// second. A session is created from the model and must not outlive it. Do
// not reorder these two.
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model_;
std::map<SessionKey, std::unique_ptr<engine::runtime::IVoiceTaskSession>> sessions_;
engine::runtime::SessionOptions session_options_;
Capabilities capabilities_;
std::string variant_;
std::string description_;
std::vector<std::string> languages_;
std::string pinned_task_;
std::string identity_;
bool supports_timestamps_ = false;
int wait_budget_ceiling_ms_ = 0;
};
// Prepares and runs an offline session. prepare() is called for every run
// rather than once per session, because SessionPreparationRequest is derived
// from the request itself (audio contract, text, voice condition) and not from
// the model: a second request with a different sample rate or length would
// otherwise run against the first request's contract.
//
// `lane` is a PROOF OF HOLDING, unused at runtime, for the same reason
// session_for takes one: the session is not reentrant and prepare() mutates it,
// so running without the lane is a data race. Making it a parameter turns that
// into a compile error instead of a comment.
//
// Throws CapabilityError when the session is not an offline one. That should be
// unreachable through session_for, which already refuses a non-offline session
// for an offline route, and is checked anyway because the alternative is a null
// dereference.
engine::runtime::TaskResult run_offline(const LoadedModel::Session &session,
const engine::runtime::TaskRequest &request,
const LaneEntry &lane);
} // namespace audiocpp_backend