mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 10:28:43 -04:00
Two hardenings ahead of the eleven handlers still to be written, both of which get harder to retrofit later. The lane proof-of-holding parameter was a const reference, which binds to a temporary, so session_for(rpc, shape, model->acquire(0)) compiled. Each such temporary dies at the end of its own full-expression, releasing the lane between two calls that must share one: precisely the split the parameter exists to prevent, and the form a future author is most likely to reach for because it reads as tidy. A non-const reference requires an lvalue, so the temporary form now fails to compile while the named-local handlers build unchanged. The header comment no longer implies the check is total either: it proves a lane was taken, not that it is this model's lane. The identity check was two lines each handler had to remember, with nothing failing if a new one forgot them and no C++ equivalent of model_identity_modalities_test.go to notice. snapshot() becomes snapshot_unchecked(), whose only legitimate caller is Status, since HealthMessage carries no ModelIdentity. Handlers go through snapshot_for(), which takes the counted reference, refuses when nothing is loaded, and runs the identity check before anything can route. Every handler already has to call something to obtain the model, so the guarded call is now the shortest path and skipping it means deliberately typing snapshot_unchecked. A convention that has to be remembered can rot; this cannot. Verified: the temporary-argument and inverted-order forms each fail to compile with the expected diagnostic, the real handlers build, and bypassing the guard in Diarize alone turns the identity test red on that RPC while VAD stays green. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
233 lines
11 KiB
C++
233 lines
11 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().
|
|
//
|
|
// NON-CONST reference on purpose, and do not "tidy" it to const. A const
|
|
// reference binds to a temporary, which makes this compile:
|
|
//
|
|
// auto session = model->session_for(rpc, shape, model->acquire(0));
|
|
// auto result = run_offline(session, task, model->acquire(0));
|
|
//
|
|
// and each temporary dies at the end of its own full-expression, so the
|
|
// lane is released between the two calls. That is precisely the split this
|
|
// parameter exists to prevent, and it is the form a future caller is most
|
|
// likely to reach for because it reads as tidy. Requiring an lvalue forces
|
|
// a named entry whose scope spans both calls.
|
|
//
|
|
// What it proves is bounded, so do not over-trust it: it proves A lane was
|
|
// taken, not THIS model's lane. A caller determined to defeat it can
|
|
// construct an entry on an unrelated InferenceLane and pass that. It
|
|
// therefore catches the two mistakes that actually happen, forgetting the
|
|
// lane entirely and taking it after routing, and does not catch lane
|
|
// identity.
|
|
//
|
|
// 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, 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. Non-const for the same reason as
|
|
// session_for's: a const reference would bind to `model.acquire(0)` written
|
|
// inline, and that temporary dies at the end of this call, releasing the lane
|
|
// before the caller's next one.
|
|
//
|
|
// 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,
|
|
LaneEntry &lane);
|
|
|
|
} // namespace audiocpp_backend
|