diff --git a/examples/server/api.md b/examples/server/api.md index 02c83b5cc4..97cbd66cdf 100644 --- a/examples/server/api.md +++ b/examples/server/api.md @@ -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: [...]}` | | 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 | -| 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 | 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. 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 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 diff --git a/examples/server/model_catalog.cpp b/examples/server/model_catalog.cpp index 2617d60074..7daed533fe 100644 --- a/examples/server/model_catalog.cpp +++ b/examples/server/model_catalog.cpp @@ -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) { #ifdef _WIN32 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) { - 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 || !valid_identifier(recipe.at("id").get()) || recipe.at("name").get().empty()) { 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) { 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 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().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")) { const auto& groups = recipe.at("required_any"); if (!groups.is_array() || groups.size() > 32) @@ -646,10 +667,11 @@ void initialize_server_models(ServerRuntime& 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"}); const auto recipe_id = body.at("recipe").get(); - 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()) throw std::runtime_error("models must be an object"); auto& models = *runtime.models; @@ -664,6 +686,20 @@ void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParam defaults = models.base_defaults; apply_recipe_context(recipe.value("context", json::object()), context); 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 allowed; for (const auto& slot : recipe.at("slots")) { const auto id = slot.at("id").get(); @@ -675,6 +711,9 @@ void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParam if (selection.contains(id)) throw std::runtime_error("specify only one of tae and taesd"); 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()); @@ -698,6 +737,20 @@ void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParam if (!allowed.count(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().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())) { if (std::none_of(group.begin(), group.end(), [&](const json& value) { return !selection.value(value.get(), std::string()).empty(); diff --git a/examples/server/model_catalog.h b/examples/server/model_catalog.h index 7d49a19241..2e45fd183f 100644 --- a/examples/server/model_catalog.h +++ b/examples/server/model_catalog.h @@ -51,6 +51,14 @@ void initialize_server_models(ServerRuntime& runtime); json resolve_recipe_features(const json& recipe); 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 diff --git a/examples/server/routes_models.cpp b/examples/server/routes_models.cpp index 9e4e3963c1..f33057758c 100644 --- a/examples/server/routes_models.cpp +++ b/examples/server/routes_models.cpp @@ -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 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); try { runtime.sd_ctx = nullptr;