Skip to content

Commit 61fe7f5

Browse files
committed
fix: preserve prior saved recipe options on partial save
A /load with save_options=true persisted only the current request's recipe-option fields, clobbering fields saved by an earlier request on the same model — e.g. saving llamacpp_args dropped a previously-saved ctx_size, and a later option-free load no longer recalled it. Merge the request over the model's prior saved Layer-3 options (get_saved_model_options) so earlier user saves survive, without baking model-JSON / image defaults (Layers 1-2) into recipe_options.json where they would shadow future registry default changes. Also clears a model's saved options on delete so a re-pull of the same id starts clean. Adds a pure-API regression test (test_012p) covering the two-save clobber sequence and the option-free recall that follows.
1 parent 31cdc92 commit 61fe7f5

5 files changed

Lines changed: 470 additions & 32 deletions

File tree

src/cpp/include/lemon/model_manager.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ class ModelManager {
226226
// removed in the directory.
227227
void set_extra_models_dir(const std::string& dir);
228228

229+
// Return ONLY this model's persisted Layer-3 saved options (recipe_options.json),
230+
// not the fully-resolved stack — for merge-preserving saves that do not bake defaults.
231+
RecipeOptions get_saved_model_options(const ModelInfo& info,
232+
bool refresh_saved_from_disk = false);
233+
229234
void save_model_options(const ModelInfo& info);
230235

231236
void start_directory_watcher();
@@ -304,6 +309,8 @@ class ModelManager {
304309

305310
// Discover GGUF models from extra_models_dir
306311
std::map<std::string, ModelInfo> discover_extra_models() const;
312+
json get_saved_recipe_options_snapshot(bool refresh_saved_from_disk);
313+
void delete_saved_model_options(const std::string& model_name);
307314

308315
json server_models_;
309316
json user_models_;

src/cpp/server/model_manager.cpp

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,32 @@ static RecipeOptions build_recipe_options(const ModelInfo& info,
660660
return RecipeOptions(info.recipe, base_options);
661661
}
662662

663+
static const json* find_model_json_entry(const json& server_models,
664+
const json& user_models,
665+
const std::string& model_name) {
666+
std::string json_key = model_name;
667+
bool is_user_model = is_user_model_name(model_name);
668+
if (is_user_model) {
669+
json_key = strip_user_model_prefix(model_name);
670+
}
671+
672+
if (is_user_model && user_models.contains(json_key)) {
673+
return &user_models.at(json_key);
674+
}
675+
if (!is_user_model && server_models.contains(json_key)) {
676+
return &server_models.at(json_key);
677+
}
678+
679+
return nullptr;
680+
}
681+
682+
static json extract_json_recipe_options(const json* model_json) {
683+
if (model_json && model_json->contains("recipe_options") && (*model_json)["recipe_options"].is_object()) {
684+
return (*model_json)["recipe_options"];
685+
}
686+
return json(nullptr);
687+
}
688+
663689
// Clean up orphaned HF cache blobs after deleting a symlink.
664690
// HF hub downloads use: snapshots/<hash>/file.gguf -> ../../blobs/<sha256>
665691
// If no remaining symlink in the repo points to the blob, it's safe to remove.
@@ -1172,6 +1198,34 @@ std::string ModelManager::get_hf_cache_dir() const {
11721198
return lemon::utils::get_hf_cache_dir();
11731199
}
11741200

1201+
json ModelManager::get_saved_recipe_options_snapshot(bool refresh_saved_from_disk) {
1202+
// The save path reads the prior persisted options into a LOCAL (refresh=true)
1203+
// so the merge sees the authoritative on-disk state without touching the shared
1204+
// recipe_options_ member — which is read by build_cache()/add_model_to_cache()
1205+
// under models_cache_mutex_, so reading it here (a different, concurrent path)
1206+
// would be a data race on the json (invariant #8). A fresh disk parse avoids
1207+
// that. With refresh=false the caller accepts the in-memory member by value.
1208+
if (refresh_saved_from_disk) {
1209+
return load_optional_json(get_recipe_options_file());
1210+
}
1211+
return recipe_options_;
1212+
}
1213+
1214+
RecipeOptions ModelManager::get_saved_model_options(const ModelInfo& info,
1215+
bool refresh_saved_from_disk) {
1216+
// Return ONLY this model's persisted Layer-3 options (recipe_options.json),
1217+
// canonical-keyed — NOT the fully-resolved effective stack. Used by the save
1218+
// path so a save merges request-over-prior-saved and preserves earlier user
1219+
// saves WITHOUT baking image_defaults / model-JSON defaults (Layers 1-2),
1220+
// which would shadow future registry default changes.
1221+
json snapshot = get_saved_recipe_options_snapshot(refresh_saved_from_disk);
1222+
const std::string key = cache_key_to_canonical_id(info.model_name);
1223+
json saved = (snapshot.contains(key) && snapshot[key].is_object())
1224+
? snapshot[key]
1225+
: json::object();
1226+
return RecipeOptions(info.recipe, saved);
1227+
}
1228+
11751229
void ModelManager::invalidate_models_cache() {
11761230
std::lock_guard<std::mutex> lock(models_cache_mutex_);
11771231
cache_valid_ = false;
@@ -1851,12 +1905,25 @@ void ModelManager::save_user_models(const json& user_models) {
18511905
void ModelManager::save_model_options(const ModelInfo& info) {
18521906
LOG(INFO, "ModelManager") << "Saving options for model: " << info.model_name << std::endl;
18531907
// Persist under canonical ID (built-ins are keyed bare in cache but
1854-
// recipe_options.json stores them as builtin.<name>).
1908+
// recipe_options.json stores them as builtin.<name>). Matches upstream's
1909+
// existing save discipline (recipe_options_ written here, read by the
1910+
// cache-build paths under models_cache_mutex_).
18551911
recipe_options_[cache_key_to_canonical_id(info.model_name)] = info.recipe_options.to_json();
18561912
update_model_options_in_cache(info);
18571913
save_user_json(get_recipe_options_file(), recipe_options_);
18581914
}
18591915

1916+
void ModelManager::delete_saved_model_options(const std::string& model_name) {
1917+
// recipe_options.json is keyed by canonical ID (built-ins as builtin.<name>),
1918+
// so erase under the canonical key or built-in entries would never be removed.
1919+
// Same write discipline as save_model_options() above.
1920+
const std::string key = cache_key_to_canonical_id(model_name);
1921+
if (recipe_options_.erase(key) > 0) {
1922+
save_user_json(get_recipe_options_file(), recipe_options_);
1923+
LOG(INFO, "ModelManager") << "✓ Removed saved recipe options for " << model_name << std::endl;
1924+
}
1925+
}
1926+
18601927
std::map<std::string, ModelInfo> ModelManager::get_supported_models() {
18611928
// Build cache if needed (lazy initialization)
18621929
build_cache();
@@ -1874,7 +1941,7 @@ std::map<std::string, ModelInfo> ModelManager::get_supported_models() {
18741941
return public_models;
18751942
}
18761943

1877-
static void load_checkpoints(ModelInfo& info, json& model_json) {
1944+
static void load_checkpoints(ModelInfo& info, const json& model_json) {
18781945
if (model_json.contains("checkpoints") && model_json["checkpoints"].is_object()) {
18791946
for (auto& [key, value] : model_json["checkpoints"].items()) {
18801947
info.checkpoints[key] = value.get<std::string>();
@@ -2007,7 +2074,6 @@ void ModelManager::build_cache() {
20072074

20082075
models_cache_.clear();
20092076
std::map<std::string, ModelInfo> all_models;
2010-
std::map<std::string, json> json_recipe_options; // Per-model recipe_options from JSON
20112077

20122078
// Step 1: Load ALL models from JSON (server models)
20132079
for (auto& [key, value] : server_models_.items()) {
@@ -2046,11 +2112,6 @@ void ModelManager::build_cache() {
20462112

20472113
parse_image_defaults(info, value);
20482114

2049-
// Parse recipe_options if present (for per-model runtime config like sdcpp_args)
2050-
if (value.contains("recipe_options") && value["recipe_options"].is_object()) {
2051-
json_recipe_options[key] = value["recipe_options"];
2052-
}
2053-
20542115
// Populate type and device fields (multi-model support)
20552116
info.type = get_model_type_from_labels(info.labels);
20562117
info.device = get_device_type_from_recipe(info.recipe);
@@ -2099,11 +2160,6 @@ void ModelManager::build_cache() {
20992160

21002161
parse_image_defaults(info, value);
21012162

2102-
// Parse recipe_options if present (for per-model runtime config like sdcpp_args)
2103-
if (value.contains("recipe_options") && value["recipe_options"].is_object()) {
2104-
json_recipe_options[info.model_name] = value["recipe_options"];
2105-
}
2106-
21072163
// Populate type and device fields (multi-model support)
21082164
info.type = get_model_type_from_labels(info.labels);
21092165
info.device = get_device_type_from_recipe(info.recipe);
@@ -2180,9 +2236,14 @@ void ModelManager::build_cache() {
21802236

21812237
// Populate recipe options. recipe_options.json is keyed by canonical ID
21822238
// (user.*, extra.*, builtin.*) — built-ins are keyed bare in the cache, so
2183-
// we translate before lookup.
2239+
// we translate before lookup. The per-model JSON recipe_options are read
2240+
// inline from the model entry (mirrors add_model_to_cache) rather than via a
2241+
// prebuilt map.
21842242
for (auto& [name, info] : all_models) {
2185-
json jro = json_recipe_options.count(name) ? json_recipe_options[name] : json(nullptr);
2243+
json jro = json(nullptr);
2244+
if (const auto* model_json = find_model_json_entry(server_models_, user_models_, name)) {
2245+
jro = extract_json_recipe_options(model_json);
2246+
}
21862247
info.recipe_options = build_recipe_options(info, jro, cache_key_to_canonical_id(name), recipe_options_);
21872248
}
21882249

@@ -2240,20 +2301,8 @@ void ModelManager::add_model_to_cache(const std::string& model_name) {
22402301
return; // Will initialize on next access
22412302
}
22422303

2243-
// Parse model name to get JSON key
2244-
std::string json_key = model_name;
22452304
bool is_user_model = is_user_model_name(model_name);
2246-
if (is_user_model) {
2247-
json_key = strip_user_model_prefix(model_name);
2248-
}
2249-
2250-
// Find in JSON
2251-
json* model_json = nullptr;
2252-
if (is_user_model && user_models_.contains(json_key)) {
2253-
model_json = &user_models_[json_key];
2254-
} else if (!is_user_model && server_models_.contains(json_key)) {
2255-
model_json = &server_models_[json_key];
2256-
}
2305+
const json* model_json = find_model_json_entry(server_models_, user_models_, model_name);
22572306

22582307
if (!model_json) {
22592308
LOG(WARNING, "ModelManager") << "'" << model_name << "' not found in JSON" << std::endl;
@@ -2271,8 +2320,7 @@ void ModelManager::add_model_to_cache(const std::string& model_name) {
22712320
info.cloud_provider = JsonUtils::get_or_default<std::string>(*model_json, "cloud_provider", "");
22722321

22732322
parse_image_defaults(info, *model_json);
2274-
json jro = (model_json->contains("recipe_options") && (*model_json)["recipe_options"].is_object())
2275-
? (*model_json)["recipe_options"] : json(nullptr);
2323+
json jro = extract_json_recipe_options(model_json);
22762324
info.recipe_options = build_recipe_options(info, jro, cache_key_to_canonical_id(model_name), recipe_options_);
22772325

22782326
info.suggested = JsonUtils::get_or_default<bool>(*model_json, "suggested", is_user_model);
@@ -4946,6 +4994,7 @@ void ModelManager::delete_model(const std::string& model_name) {
49464994
save_user_models(updated_user_models);
49474995
user_models_ = std::move(updated_user_models);
49484996
cache_valid_ = false;
4997+
delete_saved_model_options(canonical_model_name);
49494998
LOG(INFO, "ModelManager") << "✓ Removed from user_models.json" << std::endl;
49504999
}
49515000

@@ -4980,6 +5029,7 @@ void ModelManager::delete_model(const std::string& model_name) {
49805029
save_user_models(updated_user_models);
49815030
user_models_ = std::move(updated_user_models);
49825031
cache_valid_ = false;
5032+
delete_saved_model_options(canonical_model_name);
49835033
LOG(INFO, "ModelManager") << "✓ Removed from user_models.json" << std::endl;
49845034
}
49855035

@@ -5076,6 +5126,7 @@ void ModelManager::delete_model(const std::string& model_name) {
50765126
save_user_models(updated_user_models);
50775127
user_models_ = std::move(updated_user_models);
50785128
cache_valid_ = false;
5129+
delete_saved_model_options(canonical_model_name);
50795130
LOG(INFO, "ModelManager") << "✓ Removed from user_models.json" << std::endl;
50805131
}
50815132

src/cpp/server/server.cpp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3520,9 +3520,14 @@ void Server::handle_load(const httplib::Request& req, httplib::Response& res) {
35203520
LOG(INFO, "Server") << " " << options.to_log_string(false);
35213521
LOG(INFO, "Server") << std::endl;
35223522

3523-
// Persist request options to model info if requested
3523+
// Persist request options to model info if requested. Merge the request over
3524+
// the model's PRIOR persisted options (Layer 3) only — this preserves earlier
3525+
// user saves without baking image_defaults / model-JSON defaults (Layers 1-2)
3526+
// into recipe_options.json, which would shadow future registry default changes.
35243527
if (save_options) {
3525-
info.recipe_options = options;
3528+
RecipeOptions prior_saved = model_manager_->get_saved_model_options(
3529+
info, /*refresh_saved_from_disk=*/true);
3530+
info.recipe_options = options.inherit(prior_saved);
35263531
model_manager_->save_model_options(info);
35273532
}
35283533

0 commit comments

Comments
 (0)