From 92f9a27cafcbc32a671dab373ca27c794b194558 Mon Sep 17 00:00:00 2001 From: gadurp Date: Wed, 28 Oct 2026 21:01:25 -0500 Subject: [PATCH 1/2] Load an installed package by id through POST /v1/models/load With the native model manager, POST /v1/models/load accepts "package_id" in place of "path". The management handler reads the package with PackageManager::package_info() and asks the family's loader through ModelRegistry::resolve_package_load() for the path, family, load options and session options, then hands the rewritten request to the existing handle_model_load. Model-specific loading decisions stay in each model. The package_id check runs before model_installer_mutex_ is taken, in both runtimes, so path-based loads never touch the installer. - Unknown or not-installed package: 404 not_found. - Both path and package_id, an empty package_id, or a family that does not match the package: 400. - Resolved load/session options fill in what the request leaves out; keys the request sends win. - Requests with path, and servers built without the native model manager, are unchanged. Validated on a CPU build: htdemucs_q8_0 loaded by id and ran a /v1/tasks/run separation; minimax_music3_q4_0 loaded by id with no client session options and got its three component GGUFs from the resolver; path-based loading still works; server_model_installer_test passes. Co-Authored-By: Claude Opus 5.5 --- app/server/README.md | 23 ++++++++++++ app/server/main.cpp | 3 +- app/server/model_installer.cpp | 8 ++++ app/server/model_installer.h | 4 ++ app/server/parallel_runtime.cpp | 30 +++++++++++++++ app/server/parallel_runtime.h | 1 + app/server/request_dispatch.h | 4 ++ app/server/runtime.cpp | 30 +++++++++++++++ app/server/runtime.h | 1 + app/server/server_ui.cpp | 65 +++++++++++++++++++++++++++++++++ app/server/server_ui.h | 19 ++++++++++ 11 files changed, 187 insertions(+), 1 deletion(-) diff --git a/app/server/README.md b/app/server/README.md index 51137a860..4245e54ed 100644 --- a/app/server/README.md +++ b/app/server/README.md @@ -333,6 +333,29 @@ match the config's, the model keeps the config's `default_request_options`, `voice_presets`, `default_voice_preset`, `busy_timeout_ms` and `live_ingest`; a load of another family or task under that `id` gets none of them. +### Loading an installed package by id + +A server built with `AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON` also lets +`POST /v1/models/load` name an installed package with `package_id` instead of +`path`, so a client does not need to know where the models root is: + +```bash +curl -X POST http://127.0.0.1:8080/v1/models/load \ + -H 'Content-Type: application/json' \ + -d '{"id":"sep","package_id":"htdemucs_q8_0","task":"sep"}' +``` + +The server reads the package's metadata from the package manager and asks the +family's loader, through `resolve_package_load`, for the path, family, load +options and session options, the same target the WebUI loads. The request then +goes through the existing load handler as if it had sent them. `family` +defaults to the package's family; a different `family` is rejected. A +`load_options` or `session_options` key the request sends wins over the +resolved one. Each precision is its own package id, such as `kokoro_82m_q8_0` +and `kokoro_82m_bf16`. Sending both `path` and `package_id` is rejected with +400, and an unknown or not-installed package returns 404. Requests with +`path`, and servers built without the native model manager, are unchanged. + ### Paths and model specs Relative model and configured voice-library paths resolve against the config diff --git a/app/server/main.cpp b/app/server/main.cpp index 77653541e..f14e72c38 100644 --- a/app/server/main.cpp +++ b/app/server/main.cpp @@ -129,7 +129,8 @@ void print_help() { << " GET /health\n" << " GET /v1/models\n" << " GET /v1/recipes\n" - << " POST /v1/models/load available with --ui-management\n" + << " POST /v1/models/load available with --ui-management; path, or\n" + << " package_id with the native model manager\n" << " POST /v1/models/unload available with --ui-management\n" << " POST /v1/ui/upload temporary browser upload for WebUI requests\n" << " POST /v1/ui/models/install background package download/preparation\n" diff --git a/app/server/model_installer.cpp b/app/server/model_installer.cpp index f8e4f314d..3f8cc6319 100644 --- a/app/server/model_installer.cpp +++ b/app/server/model_installer.cpp @@ -564,6 +564,14 @@ std::string ModelInstaller::clean_partial(const std::string & package_id) { json_quote(message.empty() ? "Partial download files cleaned" : message) + "}"; } +std::optional ModelInstaller::package_info( + const std::string & package_id) const { + if (!valid_package_id(package_id)) { + return std::nullopt; + } + return state_->native_manager->package_info(package_id); +} + std::string ModelInstaller::remove(const std::string & package_id) { if (!valid_package_id(package_id)) { throw std::runtime_error("invalid model-manager package id"); diff --git a/app/server/model_installer.h b/app/server/model_installer.h index a1f23cde4..b2b6e14c6 100644 --- a/app/server/model_installer.h +++ b/app/server/model_installer.h @@ -1,7 +1,10 @@ #pragma once +#include "engine/framework/package_manager/manager.h" + #include #include +#include #include namespace minitts::server { @@ -30,6 +33,7 @@ class ModelInstaller { std::string stop(const std::string & package_id); std::string clean_partial(const std::string & package_id); std::string remove(const std::string & package_id); + std::optional package_info(const std::string & package_id) const; bool has_active_jobs() const; private: diff --git a/app/server/parallel_runtime.cpp b/app/server/parallel_runtime.cpp index 970544981..d461de399 100644 --- a/app/server/parallel_runtime.cpp +++ b/app/server/parallel_runtime.cpp @@ -487,6 +487,36 @@ HttpResponse ParallelServerState::handle_ui_upload(const HttpRequest & request) } #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) +// Resolves a "package_id" to the installed package's load target before +// handing the request to handle_model_load, which only ever sees a path. +// Path-based loads never touch the installer or its mutex. +HttpResponse ParallelServerState::handle_package_model_load(const std::string & body_text) { + if (!config_.ui_management) { + return handle_model_load(body_text); + } + const auto body = engine::io::json::parse(body_text); + std::string package_id; + if (auto error = ui_model_load_package_id(body, package_id)) { + return *error; + } + if (package_id.empty()) { + return handle_model_load(body_text); + } + std::optional info; + { + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model management is disabled", "forbidden"); + } + info = model_installer_->package_info(package_id); + } + std::string resolved_body; + if (auto error = resolve_ui_package_model_load(body, package_id, info, resolved_body)) { + return *error; + } + return handle_model_load(resolved_body); +} + HttpResponse ParallelServerState::handle_model_install(const std::string & body_text) { if (!config_.ui_management) { return error_response(403, "UI model installation is disabled", "forbidden"); diff --git a/app/server/parallel_runtime.h b/app/server/parallel_runtime.h index 030d1c39e..31fc479c9 100644 --- a/app/server/parallel_runtime.h +++ b/app/server/parallel_runtime.h @@ -143,6 +143,7 @@ class ParallelServerState final : public IHttpHandler, public ServerFrontendCont HttpResponse handle_path_status(const std::string & body_text) const; HttpResponse handle_ui_upload(const HttpRequest & request); #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) + HttpResponse handle_package_model_load(const std::string & body_text); HttpResponse handle_model_install(const std::string & body_text); HttpResponse handle_model_install_stop(const std::string & body_text); HttpResponse handle_model_clean_partial(const std::string & body_text); diff --git a/app/server/request_dispatch.h b/app/server/request_dispatch.h index 4d2ddf02e..7346aec84 100644 --- a/app/server/request_dispatch.h +++ b/app/server/request_dispatch.h @@ -62,7 +62,11 @@ struct RequestDispatcher { response = state.handle_ui_voice_preview(request); } else if (request.method == "POST" && request.path == "/v1/models/load") { +#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) + response = state.handle_package_model_load(request.body); +#else response = state.handle_model_load(request.body); +#endif } else if (request.method == "POST" && request.path == "/v1/models/unload") { response = state.handle_model_unload(request.body); diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index 91aca745a..ebb82c380 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -426,6 +426,36 @@ HttpResponse ServerState::handle_ui_upload(const HttpRequest & request) { } #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) +// Resolves a "package_id" to the installed package's load target before +// handing the request to handle_model_load, which only ever sees a path. +// Path-based loads never touch the installer or its mutex. +HttpResponse ServerState::handle_package_model_load(const std::string & body_text) { + if (!config_.ui_management) { + return handle_model_load(body_text); + } + const auto body = engine::io::json::parse(body_text); + std::string package_id; + if (auto error = ui_model_load_package_id(body, package_id)) { + return *error; + } + if (package_id.empty()) { + return handle_model_load(body_text); + } + std::optional info; + { + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model management is disabled", "forbidden"); + } + info = model_installer_->package_info(package_id); + } + std::string resolved_body; + if (auto error = resolve_ui_package_model_load(body, package_id, info, resolved_body)) { + return *error; + } + return handle_model_load(resolved_body); +} + HttpResponse ServerState::handle_model_install(const std::string & body_text) { if (!config_.ui_management) { return error_response(403, "UI model installation is disabled", "forbidden"); diff --git a/app/server/runtime.h b/app/server/runtime.h index 56ca80ad6..7468ad7e3 100644 --- a/app/server/runtime.h +++ b/app/server/runtime.h @@ -116,6 +116,7 @@ class ServerState final : public IHttpHandler, public ServerFrontendContext { HttpResponse handle_path_status(const std::string & body_text) const; HttpResponse handle_ui_upload(const HttpRequest & request); #if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) + HttpResponse handle_package_model_load(const std::string & body_text); HttpResponse handle_model_install(const std::string & body_text); HttpResponse handle_model_install_stop(const std::string & body_text); HttpResponse handle_model_clean_partial(const std::string & body_text); diff --git a/app/server/server_ui.cpp b/app/server/server_ui.cpp index 4aaa028ab..cc1832643 100644 --- a/app/server/server_ui.cpp +++ b/app/server/server_ui.cpp @@ -277,6 +277,71 @@ HttpResponse ui_model_package_sizes_response(ModelInstaller &installer) { return json_response(json::stringify(json::Value::make_object(std::move(inventory)))); } +std::optional ui_model_load_package_id( + const engine::io::json::Value &body, + std::string &package_id) { + package_id.clear(); + const auto *package_value = body.find("package_id"); + if (package_value == nullptr) { + return std::nullopt; + } + if (!package_value->is_string() || package_value->as_string().empty()) { + return error_response(400, "package_id must be a non-empty string", "invalid_request_error"); + } + if (body.find("path") != nullptr) { + return error_response(400, "send either path or package_id, not both", "invalid_request_error"); + } + package_id = package_value->as_string(); + return std::nullopt; +} + +std::optional resolve_ui_package_model_load( + const engine::io::json::Value &body, + const std::string &package_id, + const std::optional &info, + std::string &resolved_body) { + namespace json = engine::io::json; + if (!info) { + return error_response(404, "unknown package id: " + package_id, "not_found"); + } + if (!info->installed) { + return error_response(404, "package is not installed: " + package_id, "not_found"); + } + engine::runtime::PackageLoadTarget target; + try { + target = engine::runtime::make_default_registry().resolve_package_load(info->family, info->package); + } catch (const std::exception &error) { + return error_response(400, "package " + package_id + " cannot be loaded: " + error.what(), + "invalid_request_error"); + } + auto fields = body.as_object(); + if (const auto found = fields.find("family"); found == fields.end()) { + fields["family"] = json::Value::make_string(target.family); + } else if (!found->second.is_string() || found->second.as_string() != target.family) { + return error_response(400, + "package " + package_id + " is for family " + target.family, + "invalid_request_error"); + } + // Resolved options fill in what the request leaves out; keys it sends win. + const auto merge_options = [&](const char *key, const std::unordered_map &resolved) { + if (resolved.empty()) return; + json::Value::Object options; + if (const auto found = fields.find(key); found != fields.end() && found->second.is_object()) { + options = found->second.as_object(); + } + for (const auto &option : resolved) { + options.emplace(option.first, json::Value::make_string(option.second)); + } + fields[key] = json::Value::make_object(std::move(options)); + }; + merge_options("load_options", target.load_options); + merge_options("session_options", target.session_options); + fields.erase("package_id"); + fields["path"] = json::Value::make_string(target.model_path.string()); + resolved_body = json::stringify(json::Value::make_object(std::move(fields))); + return std::nullopt; +} + HttpResponse ui_models_root_response( const std::filesystem::path &models_root, const std::filesystem::path &default_models_root) { diff --git a/app/server/server_ui.h b/app/server/server_ui.h index 97d634c3d..0fc1d922b 100644 --- a/app/server/server_ui.h +++ b/app/server/server_ui.h @@ -1,6 +1,10 @@ #pragma once #include "http.h" +#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER) +#include "engine/framework/io/json.h" +#include "engine/framework/package_manager/manager.h" +#endif #include #include @@ -58,6 +62,21 @@ HttpResponse ui_model_install_status_response( const HttpRequest &request, const ModelInstaller &installer); HttpResponse ui_model_package_sizes_response(ModelInstaller &installer); +// POST /v1/models/load may name an installed package by "package_id" instead +// of "path". Sets package_id to the requested id, or leaves it empty for a +// path-based request; returns an error response for a malformed package_id. +std::optional ui_model_load_package_id( + const engine::io::json::Value &body, + std::string &package_id); +// Rewrites a package_id load into the path-based request the loader already +// takes, using the model-owned resolve_package_load for the path, family, load +// options and session options. Returns the error response instead when the +// package cannot be loaded by id. +std::optional resolve_ui_package_model_load( + const engine::io::json::Value &body, + const std::string &package_id, + const std::optional &info, + std::string &resolved_body); HttpResponse ui_models_root_response( const std::filesystem::path &models_root, const std::filesystem::path &default_models_root); From 8e861cd15844cc3a1ec52bd82db4bab63c589358 Mon Sep 17 00:00:00 2001 From: 0xShug0 <231717474+0xShug0@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:06:18 -0400 Subject: [PATCH 2/2] Reject malformed package load option maps --- app/server/server_ui.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/server/server_ui.cpp b/app/server/server_ui.cpp index cc1832643..d2e1d9fbb 100644 --- a/app/server/server_ui.cpp +++ b/app/server/server_ui.cpp @@ -322,6 +322,12 @@ std::optional resolve_ui_package_model_load( "package " + package_id + " is for family " + target.family, "invalid_request_error"); } + for (const char *key : {"load_options", "session_options"}) { + const auto *options = body.find(key); + if (options && !options->is_object() && !options->is_null()) { + return error_response(400, std::string(key) + " must be an object or null", "invalid_request_error"); + } + } // Resolved options fill in what the request leaves out; keys it sends win. const auto merge_options = [&](const char *key, const std::unordered_map &resolved) { if (resolved.empty()) return;