mirror of
https://github.com/leejet/stable-diffusion.cpp.git
synced 2026-11-11 19:36:33 +00:00
feat(server): add recipe model defaults and idempotent model loading (#2221)
This commit is contained in:
1 parent
9bfc4d1089
commit
209b28c3cd
4 files changed
+93
-6
No files matched your search
@@ -85,7 +85,7 @@ available during loading. Model lists are empty when no model is loaded;
|
|||||||
| GET | `/sdcpp/v1/model-files` | `{files: [{id, name, category, root, relative_path}], errors: [...]}` |
|
| GET | `/sdcpp/v1/model-files` | `{files: [{id, name, category, root, relative_path}], errors: [...]}` |
|
||||||
| POST | `/sdcpp/v1/models/refresh` | Rescan recipes and files; return `{errors: [...]}` |
|
| POST | `/sdcpp/v1/models/refresh` | Rescan recipes and files; return `{errors: [...]}` |
|
||||||
| GET | `/sdcpp/v1/models/status` | Current state, recipe, selection, error, loading time and optional revision |
|
| GET | `/sdcpp/v1/models/status` | Current state, recipe, selection, error, loading time and optional revision |
|
||||||
| POST | `/sdcpp/v1/models/load` | Load the submitted recipe and selection; return status after completion |
|
| POST | `/sdcpp/v1/models/load` | Load the submitted recipe and selection; return status after completion. A load is a no-op returning the current status with `already_loaded: true` only when the state is ready and the fully resolved selection, the complete recipe object, and the resolved component paths all match the active model; a catalog refresh with modified recipe metadata or a retargeted model path therefore reloads even with an unchanged recipe ID and selection |
|
||||||
| POST | `/sdcpp/v1/models/unload` | Release the active context; return empty status |
|
| POST | `/sdcpp/v1/models/unload` | Release the active context; return empty status |
|
||||||
|
|
||||||
Load request:
|
Load request:
|
||||||
@@ -269,6 +269,14 @@ Legacy `category` fields remain accepted, but must match one of the slot's
|
|||||||
supported directories; they cannot redefine or restrict its search directories.
|
supported directories; they cannot redefine or restrict its search directories.
|
||||||
At least one required `model` or `diffusion_model` slot is necessary.
|
At least one required `model` or `diffusion_model` slot is necessary.
|
||||||
|
|
||||||
|
An optional top-level `models` object maps slot IDs to file IDs, providing
|
||||||
|
recipe-local defaults for component selection. A load request may omit
|
||||||
|
`models` entirely and load with the recipe's defaults. Selections sent in the
|
||||||
|
request always override recipe defaults, slot by slot. An empty string
|
||||||
|
disables a slot's recipe default (omitting the slot selects it); the empty
|
||||||
|
key is then normalized out of the reported selection, so for slots without a
|
||||||
|
recipe default an empty selection loads identically to an omitted one.
|
||||||
|
|
||||||
`size_multiple` is a positive integer from 1 to 4096 that declares the width and
|
`size_multiple` is a positive integer from 1 to 4096 that declares the width and
|
||||||
height increment used by the web UI. It defaults to `8` when omitted; the UI also
|
height increment used by the web UI. It defaults to `8` when omitted; the UI also
|
||||||
uses `8` before a recipe is selected. For example, `"size_multiple": 16` makes
|
uses `8` before a recipe is selected. For example, `"size_multiple": 16` makes
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ static void clear_model_paths(SDContextParams& params) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool component_paths_match(const SDContextParams& a, const SDContextParams& b) {
|
||||||
|
for (const auto& slot : k_model_slots) {
|
||||||
|
if ((a.*slot.field) != (b.*slot.field))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
std::string model_path_key(const fs::path& path) {
|
std::string model_path_key(const fs::path& path) {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
const auto value = path.generic_wstring();
|
const auto value = path.generic_wstring();
|
||||||
@@ -406,7 +414,7 @@ static void validate_video_constraints(const json& constraints) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void validate_recipe(const json& recipe) {
|
static void validate_recipe(const json& recipe) {
|
||||||
check_keys(recipe, {"schema_version", "id", "name", "description", "model_url", "slots", "required_any", "defaults", "context", "features", "disabled_features", "size_multiple", "video_constraints"});
|
check_keys(recipe, {"schema_version", "id", "name", "description", "model_url", "slots", "required_any", "models", "defaults", "context", "features", "disabled_features", "size_multiple", "video_constraints"});
|
||||||
if (!recipe.at("schema_version").is_number_integer() || recipe.at("schema_version") != 1 ||
|
if (!recipe.at("schema_version").is_number_integer() || recipe.at("schema_version") != 1 ||
|
||||||
!valid_identifier(recipe.at("id").get<std::string>()) || recipe.at("name").get<std::string>().empty()) {
|
!valid_identifier(recipe.at("id").get<std::string>()) || recipe.at("name").get<std::string>().empty()) {
|
||||||
throw std::runtime_error("invalid schema_version, id or name");
|
throw std::runtime_error("invalid schema_version, id or name");
|
||||||
@@ -449,6 +457,19 @@ static void validate_recipe(const json& recipe) {
|
|||||||
if (!has_required_model) {
|
if (!has_required_model) {
|
||||||
throw std::runtime_error("a required model or diffusion_model slot is needed");
|
throw std::runtime_error("a required model or diffusion_model slot is needed");
|
||||||
}
|
}
|
||||||
|
// Optional recipe-local model defaults: slot ID to file ID mappings that
|
||||||
|
// prefill slots a load request does not select itself.
|
||||||
|
if (recipe.contains("models")) {
|
||||||
|
const auto& recipe_models = recipe.at("models");
|
||||||
|
if (!recipe_models.is_object() || recipe_models.size() > 32)
|
||||||
|
throw std::runtime_error("models must be an object of at most 32 slot entries");
|
||||||
|
std::set<std::string> default_ids;
|
||||||
|
for (auto it = recipe_models.begin(); it != recipe_models.end(); ++it) {
|
||||||
|
if (!ids.count(it.key() == "taesd" ? "tae" : it.key()) || !it.value().is_string() || it.value().get<std::string>().empty() ||
|
||||||
|
!default_ids.insert(it.key() == "taesd" ? "tae" : it.key()).second)
|
||||||
|
throw std::runtime_error("unknown or invalid default model: " + it.key());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (recipe.contains("required_any")) {
|
if (recipe.contains("required_any")) {
|
||||||
const auto& groups = recipe.at("required_any");
|
const auto& groups = recipe.at("required_any");
|
||||||
if (!groups.is_array() || groups.size() > 32)
|
if (!groups.is_array() || groups.size() > 32)
|
||||||
@@ -646,10 +667,11 @@ void initialize_server_models(ServerRuntime& runtime) {
|
|||||||
refresh_model_catalog(runtime);
|
refresh_model_catalog(runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParams& context, SDGenerationParams& defaults, json* loaded_recipe) {
|
void prepare_model_load(ServerRuntime& runtime, json& body, SDContextParams& context, SDGenerationParams& defaults, json* loaded_recipe) {
|
||||||
check_keys(body, {"recipe", "models"});
|
check_keys(body, {"recipe", "models"});
|
||||||
const auto recipe_id = body.at("recipe").get<std::string>();
|
const auto recipe_id = body.at("recipe").get<std::string>();
|
||||||
auto selection = body.at("models");
|
// `models` is optional; unselected slots may fall back to recipe defaults below.
|
||||||
|
auto selection = body.value("models", json::object());
|
||||||
if (!selection.is_object())
|
if (!selection.is_object())
|
||||||
throw std::runtime_error("models must be an object");
|
throw std::runtime_error("models must be an object");
|
||||||
auto& models = *runtime.models;
|
auto& models = *runtime.models;
|
||||||
@@ -664,6 +686,20 @@ void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParam
|
|||||||
defaults = models.base_defaults;
|
defaults = models.base_defaults;
|
||||||
apply_recipe_context(recipe.value("context", json::object()), context);
|
apply_recipe_context(recipe.value("context", json::object()), context);
|
||||||
apply_recipe_defaults(recipe.value("defaults", json::object()), defaults);
|
apply_recipe_defaults(recipe.value("defaults", json::object()), defaults);
|
||||||
|
// Recipe-provided default models fill any slot the request did not select.
|
||||||
|
// An explicit selection blocks the default for that slot, including an
|
||||||
|
// empty string (a deliberate opt-out of the default). The tae/taesd pair
|
||||||
|
// is one slot: an explicit request selection of either spelling overrides
|
||||||
|
// the recipe default for that slot.
|
||||||
|
if (recipe.contains("models")) {
|
||||||
|
for (auto it = recipe.at("models").begin(); it != recipe.at("models").end(); ++it) {
|
||||||
|
if (selection.contains(it.key()))
|
||||||
|
continue;
|
||||||
|
if ((it.key() == "tae" || it.key() == "taesd") && (selection.contains("tae") || selection.contains("taesd")))
|
||||||
|
continue;
|
||||||
|
selection[it.key()] = it.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
std::set<std::string> allowed;
|
std::set<std::string> allowed;
|
||||||
for (const auto& slot : recipe.at("slots")) {
|
for (const auto& slot : recipe.at("slots")) {
|
||||||
const auto id = slot.at("id").get<std::string>();
|
const auto id = slot.at("id").get<std::string>();
|
||||||
@@ -675,6 +711,9 @@ void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParam
|
|||||||
if (selection.contains(id))
|
if (selection.contains(id))
|
||||||
throw std::runtime_error("specify only one of tae and taesd");
|
throw std::runtime_error("specify only one of tae and taesd");
|
||||||
selection[id] = selection.at(alias);
|
selection[id] = selection.at(alias);
|
||||||
|
// Keep only the declared spelling: equivalent loads must resolve
|
||||||
|
// to an identical selection for the idempotent-load comparison.
|
||||||
|
selection.erase(alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const std::string file_id = selection.value(id, std::string());
|
const std::string file_id = selection.value(id, std::string());
|
||||||
@@ -698,6 +737,20 @@ void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParam
|
|||||||
if (!allowed.count(it.key()))
|
if (!allowed.count(it.key()))
|
||||||
throw std::runtime_error("unknown model slot: " + it.key());
|
throw std::runtime_error("unknown model slot: " + it.key());
|
||||||
}
|
}
|
||||||
|
// Omitting an optional slot and sending it as an empty string resolve to
|
||||||
|
// the same context; drop the empty key so equivalent loads compare
|
||||||
|
// identically instead of rebuilding the context. The explicit empty
|
||||||
|
// string already overrode any recipe default during the merge, so its
|
||||||
|
// intent is preserved.
|
||||||
|
for (auto it = selection.begin(); it != selection.end();) {
|
||||||
|
if (!it->is_string() || !it->get<std::string>().empty()) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
it = selection.erase(it);
|
||||||
|
}
|
||||||
|
// Echo the fully resolved selection (recipe defaults merged in) for status reporting.
|
||||||
|
body["models"] = selection;
|
||||||
for (const auto& group : recipe.value("required_any", json::array())) {
|
for (const auto& group : recipe.value("required_any", json::array())) {
|
||||||
if (std::none_of(group.begin(), group.end(), [&](const json& value) {
|
if (std::none_of(group.begin(), group.end(), [&](const json& value) {
|
||||||
return !selection.value(value.get<std::string>(), std::string()).empty();
|
return !selection.value(value.get<std::string>(), std::string()).empty();
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ void initialize_server_models(ServerRuntime& runtime);
|
|||||||
json resolve_recipe_features(const json& recipe);
|
json resolve_recipe_features(const json& recipe);
|
||||||
json model_generation_features(const ServerRuntime& runtime);
|
json model_generation_features(const ServerRuntime& runtime);
|
||||||
|
|
||||||
void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParams& context, SDGenerationParams& defaults, json* loaded_recipe = nullptr);
|
// Resolves the load request into context/generation params. `body` is updated
|
||||||
|
// so `models` reflects the fully resolved selection (recipe defaults merged in),
|
||||||
|
// letting the caller report exactly which components got loaded.
|
||||||
|
void prepare_model_load(ServerRuntime& runtime, json& body, SDContextParams& context, SDGenerationParams& defaults, json* loaded_recipe = nullptr);
|
||||||
|
|
||||||
|
// True when both parameter sets resolve to the same component files. File IDs
|
||||||
|
// stay stable if a model directory symlink is retargeted inside the configured
|
||||||
|
// root, so the idempotent-load check compares the resolved paths too.
|
||||||
|
bool component_paths_match(const SDContextParams& a, const SDContextParams& b);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -84,8 +84,26 @@ static void change_model(ServerRuntime& runtime, const httplib::Request& req, ht
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idempotent load: if this exact recipe and resolved selection are already
|
||||||
|
// active, report the current status instead of tearing down the context.
|
||||||
|
// Comparing full recipe objects (not just the id) also reloads when the
|
||||||
|
// catalog was refreshed with modified recipe metadata for the same id.
|
||||||
|
// Comparing resolved component paths catches the same file IDs resolving
|
||||||
|
// to different files (e.g. a model directory symlink retargeted inside
|
||||||
|
// the configured root since the catalog was last scanned).
|
||||||
|
if (!unload) {
|
||||||
|
json current = model_status(runtime);
|
||||||
|
if (current.value("state", "") == "ready" && current.value("recipe", json(nullptr)) == body.at("recipe") &&
|
||||||
|
current.value("models", json::object()) == body.at("models") && models.active_recipe == loaded_recipe &&
|
||||||
|
component_paths_match(*runtime.ctx_params, context)) {
|
||||||
|
current["already_loaded"] = true;
|
||||||
|
res.set_content(current.dump(), "application/json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const json recipe = unload ? json(nullptr) : body.at("recipe");
|
const json recipe = unload ? json(nullptr) : body.at("recipe");
|
||||||
const json selection = unload ? json::object() : body.at("models");
|
const json selection = unload ? json::object() : body.value("models", json::object());
|
||||||
set_model_status(runtime, "loading", recipe, selection);
|
set_model_status(runtime, "loading", recipe, selection);
|
||||||
try {
|
try {
|
||||||
runtime.sd_ctx = nullptr;
|
runtime.sd_ctx = nullptr;
|
||||||
|
|||||||
Reference in new issue
Block a user