Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion examples/server/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
59 changes: 56 additions & 3 deletions examples/server/model_catalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<std::string>()) || recipe.at("name").get<std::string>().empty()) {
throw std::runtime_error("invalid schema_version, id or name");
Expand Down Expand Up @@ -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<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")) {
const auto& groups = recipe.at("required_any");
if (!groups.is_array() || groups.size() > 32)
Expand Down Expand Up @@ -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<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())
throw std::runtime_error("models must be an object");
auto& models = *runtime.models;
Expand All @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}
std::set<std::string> allowed;
for (const auto& slot : recipe.at("slots")) {
const auto id = slot.at("id").get<std::string>();
Expand All @@ -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());
Expand All @@ -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<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())) {
if (std::none_of(group.begin(), group.end(), [&](const json& value) {
return !selection.value(value.get<std::string>(), std::string()).empty();
Expand Down
10 changes: 9 additions & 1 deletion examples/server/model_catalog.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 19 additions & 1 deletion examples/server/routes_models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading