Skip to content

Commit b3adcfb

Browse files
committed
fix: harden persisted recipe option recall
1 parent 95c5f65 commit b3adcfb

6 files changed

Lines changed: 544 additions & 35 deletions

File tree

src/cpp/include/lemon/model_manager.h

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

229+
// Resolve current per-model recipe options using model defaults plus persisted overrides.
230+
RecipeOptions get_effective_recipe_options(const ModelInfo& info,
231+
bool refresh_saved_from_disk = false);
232+
233+
// Return ONLY this model's persisted Layer-3 saved options (recipe_options.json),
234+
// not the fully-resolved stack — for merge-preserving saves that do not bake defaults.
235+
RecipeOptions get_saved_model_options(const ModelInfo& info,
236+
bool refresh_saved_from_disk = false);
237+
229238
void save_model_options(const ModelInfo& info);
230239

231240
void start_directory_watcher();
@@ -304,6 +313,10 @@ class ModelManager {
304313

305314
// Discover GGUF models from extra_models_dir
306315
std::map<std::string, ModelInfo> discover_extra_models() const;
316+
json get_saved_recipe_options_snapshot(bool refresh_saved_from_disk);
317+
RecipeOptions resolve_effective_recipe_options(const ModelInfo& info,
318+
const json& saved_recipe_options) const;
319+
void delete_saved_model_options(const std::string& model_name);
307320

308321
json server_models_;
309322
json user_models_;

src/cpp/server/model_manager.cpp

Lines changed: 125 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,78 @@ 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+
// On a load-time refresh, read recipe_options.json into a LOCAL and return it
1203+
// for this resolution only — do NOT reassign the shared recipe_options_ member
1204+
// from the load path. The member is read by build_cache()/add_model_to_cache()
1205+
// under models_cache_mutex_, so mutating it here (a different, concurrent path)
1206+
// would be a data race on the json (invariant #8). Resolving against a local
1207+
// disk read is enough to honor an out-of-band edit on this load.
1208+
//
1209+
// FUTURE (option c, deferred to a separate concurrency PR): unify ALL
1210+
// recipe_options_ access under a single lock (models_cache_mutex_) and refresh
1211+
// the in-memory member here too, so /models listings also reflect out-of-band
1212+
// edits immediately. Not done here to keep this PR focused on recall and to
1213+
// avoid a lock-ordering refactor of upstream's existing save/cache paths.
1214+
if (refresh_saved_from_disk) {
1215+
return load_optional_json(get_recipe_options_file());
1216+
}
1217+
return recipe_options_;
1218+
}
1219+
1220+
RecipeOptions ModelManager::get_saved_model_options(const ModelInfo& info,
1221+
bool refresh_saved_from_disk) {
1222+
// Return ONLY this model's persisted Layer-3 options (recipe_options.json),
1223+
// canonical-keyed — NOT the fully-resolved effective stack. Used by the save
1224+
// path so a save merges request-over-prior-saved and preserves earlier user
1225+
// saves WITHOUT baking image_defaults / model-JSON defaults (Layers 1-2),
1226+
// which would shadow future registry default changes.
1227+
json snapshot = get_saved_recipe_options_snapshot(refresh_saved_from_disk);
1228+
const std::string key = cache_key_to_canonical_id(info.model_name);
1229+
json saved = (snapshot.contains(key) && snapshot[key].is_object())
1230+
? snapshot[key]
1231+
: json::object();
1232+
return RecipeOptions(info.recipe, saved);
1233+
}
1234+
1235+
RecipeOptions ModelManager::resolve_effective_recipe_options(const ModelInfo& info,
1236+
const json& saved_recipe_options) const {
1237+
json json_recipe_options = json(nullptr);
1238+
if (const auto* model_json = find_model_json_entry(server_models_, user_models_, info.model_name)) {
1239+
json_recipe_options = extract_json_recipe_options(model_json);
1240+
}
1241+
1242+
// recipe_options.json is keyed by canonical ID (built-ins as builtin.<name>);
1243+
// look up the saved snapshot under that key so built-in models recall too.
1244+
return build_recipe_options(info, json_recipe_options,
1245+
cache_key_to_canonical_id(info.model_name), saved_recipe_options);
1246+
}
1247+
1248+
RecipeOptions ModelManager::get_effective_recipe_options(const ModelInfo& info,
1249+
bool refresh_saved_from_disk) {
1250+
// Read recipe_options.json OUTSIDE any lock (disk I/O), then resolve UNDER
1251+
// models_cache_mutex_: resolve_effective_recipe_options reads the shared
1252+
// server_models_/user_models_ registries (via find_model_json_entry) and must
1253+
// honor the same lock the cache-build paths use (invariant #8). This method is
1254+
// only reached from request handlers (Router::load_model, Server::handle_load),
1255+
// never while models_cache_mutex_ is already held, so the scoped lock cannot
1256+
// recurse. update_model_options_in_cache re-acquires the lock after release.
1257+
json saved_snapshot = get_saved_recipe_options_snapshot(refresh_saved_from_disk);
1258+
RecipeOptions resolved;
1259+
{
1260+
std::lock_guard<std::mutex> lock(models_cache_mutex_);
1261+
resolved = resolve_effective_recipe_options(info, saved_snapshot);
1262+
}
1263+
1264+
if (refresh_saved_from_disk) {
1265+
ModelInfo updated = info;
1266+
updated.recipe_options = resolved;
1267+
update_model_options_in_cache(updated);
1268+
}
1269+
1270+
return resolved;
1271+
}
1272+
11751273
void ModelManager::invalidate_models_cache() {
11761274
std::lock_guard<std::mutex> lock(models_cache_mutex_);
11771275
cache_valid_ = false;
@@ -1851,12 +1949,25 @@ void ModelManager::save_user_models(const json& user_models) {
18511949
void ModelManager::save_model_options(const ModelInfo& info) {
18521950
LOG(INFO, "ModelManager") << "Saving options for model: " << info.model_name << std::endl;
18531951
// Persist under canonical ID (built-ins are keyed bare in cache but
1854-
// recipe_options.json stores them as builtin.<name>).
1952+
// recipe_options.json stores them as builtin.<name>). Matches upstream's
1953+
// existing save discipline (recipe_options_ written here, read by the
1954+
// cache-build paths under models_cache_mutex_).
18551955
recipe_options_[cache_key_to_canonical_id(info.model_name)] = info.recipe_options.to_json();
18561956
update_model_options_in_cache(info);
18571957
save_user_json(get_recipe_options_file(), recipe_options_);
18581958
}
18591959

1960+
void ModelManager::delete_saved_model_options(const std::string& model_name) {
1961+
// recipe_options.json is keyed by canonical ID (built-ins as builtin.<name>),
1962+
// so erase under the canonical key or built-in entries would never be removed.
1963+
// Same write discipline as save_model_options() above.
1964+
const std::string key = cache_key_to_canonical_id(model_name);
1965+
if (recipe_options_.erase(key) > 0) {
1966+
save_user_json(get_recipe_options_file(), recipe_options_);
1967+
LOG(INFO, "ModelManager") << "✓ Removed saved recipe options for " << model_name << std::endl;
1968+
}
1969+
}
1970+
18601971
std::map<std::string, ModelInfo> ModelManager::get_supported_models() {
18611972
// Build cache if needed (lazy initialization)
18621973
build_cache();
@@ -1874,7 +1985,7 @@ std::map<std::string, ModelInfo> ModelManager::get_supported_models() {
18741985
return public_models;
18751986
}
18761987

1877-
static void load_checkpoints(ModelInfo& info, json& model_json) {
1988+
static void load_checkpoints(ModelInfo& info, const json& model_json) {
18781989
if (model_json.contains("checkpoints") && model_json["checkpoints"].is_object()) {
18791990
for (auto& [key, value] : model_json["checkpoints"].items()) {
18801991
info.checkpoints[key] = value.get<std::string>();
@@ -2007,7 +2118,6 @@ void ModelManager::build_cache() {
20072118

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

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

20472157
parse_image_defaults(info, value);
20482158

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-
20542159
// Populate type and device fields (multi-model support)
20552160
info.type = get_model_type_from_labels(info.labels);
20562161
info.device = get_device_type_from_recipe(info.recipe);
@@ -2099,11 +2204,6 @@ void ModelManager::build_cache() {
20992204

21002205
parse_image_defaults(info, value);
21012206

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-
21072207
// Populate type and device fields (multi-model support)
21082208
info.type = get_model_type_from_labels(info.labels);
21092209
info.device = get_device_type_from_recipe(info.recipe);
@@ -2180,9 +2280,14 @@ void ModelManager::build_cache() {
21802280

21812281
// Populate recipe options. recipe_options.json is keyed by canonical ID
21822282
// (user.*, extra.*, builtin.*) — built-ins are keyed bare in the cache, so
2183-
// we translate before lookup.
2283+
// we translate before lookup. The per-model JSON recipe_options are read
2284+
// inline from the model entry (mirrors resolve_effective_recipe_options /
2285+
// add_model_to_cache) rather than via a prebuilt map.
21842286
for (auto& [name, info] : all_models) {
2185-
json jro = json_recipe_options.count(name) ? json_recipe_options[name] : json(nullptr);
2287+
json jro = json(nullptr);
2288+
if (const auto* model_json = find_model_json_entry(server_models_, user_models_, name)) {
2289+
jro = extract_json_recipe_options(model_json);
2290+
}
21862291
info.recipe_options = build_recipe_options(info, jro, cache_key_to_canonical_id(name), recipe_options_);
21872292
}
21882293

@@ -2240,20 +2345,8 @@ void ModelManager::add_model_to_cache(const std::string& model_name) {
22402345
return; // Will initialize on next access
22412346
}
22422347

2243-
// Parse model name to get JSON key
2244-
std::string json_key = model_name;
22452348
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-
}
2349+
const json* model_json = find_model_json_entry(server_models_, user_models_, model_name);
22572350

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

22732366
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);
2367+
json jro = extract_json_recipe_options(model_json);
22762368
info.recipe_options = build_recipe_options(info, jro, cache_key_to_canonical_id(model_name), recipe_options_);
22772369

22782370
info.suggested = JsonUtils::get_or_default<bool>(*model_json, "suggested", is_user_model);
@@ -4946,6 +5038,7 @@ void ModelManager::delete_model(const std::string& model_name) {
49465038
save_user_models(updated_user_models);
49475039
user_models_ = std::move(updated_user_models);
49485040
cache_valid_ = false;
5041+
delete_saved_model_options(canonical_model_name);
49495042
LOG(INFO, "ModelManager") << "✓ Removed from user_models.json" << std::endl;
49505043
}
49515044

@@ -4980,6 +5073,7 @@ void ModelManager::delete_model(const std::string& model_name) {
49805073
save_user_models(updated_user_models);
49815074
user_models_ = std::move(updated_user_models);
49825075
cache_valid_ = false;
5076+
delete_saved_model_options(canonical_model_name);
49835077
LOG(INFO, "ModelManager") << "✓ Removed from user_models.json" << std::endl;
49845078
}
49855079

@@ -5076,6 +5170,7 @@ void ModelManager::delete_model(const std::string& model_name) {
50765170
save_user_models(updated_user_models);
50775171
user_models_ = std::move(updated_user_models);
50785172
cache_valid_ = false;
5173+
delete_saved_model_options(canonical_model_name);
50795174
LOG(INFO, "ModelManager") << "✓ Removed from user_models.json" << std::endl;
50805175
}
50815176

src/cpp/server/router.cpp

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,14 +353,24 @@ void Router::load_model(const std::string& model_name,
353353
const std::string canonical_model_name = resolve_model_name(model_name);
354354
const std::string backend_option = model_info.recipe + "_backend";
355355

356-
RecipeOptions tentative = options.inherit(model_info.recipe_options.inherit(
356+
// Re-read persisted per-model options from disk so an updated
357+
// recipe_options.json is honored on this load without a server restart.
358+
RecipeOptions current_model_options = model_manager_->get_effective_recipe_options(
359+
model_info, /*refresh_saved_from_disk=*/true);
360+
361+
// First pass: resolve which backend, from request + refreshed persisted +
362+
// backend-agnostic global defaults.
363+
RecipeOptions tentative = options.inherit(current_model_options.inherit(
357364
RecipeOptions(model_info.recipe, config_->recipe_options(""))));
358365
json backend_json = tentative.get_option(backend_option);
359366
const std::string backend = backend_json.is_string() ? backend_json.get<std::string>() : "";
360367

361-
// Second pass: rebuild defaults using the resolved backend
368+
// Second pass: rebuild defaults using the resolved backend.
362369
RecipeOptions default_opt = RecipeOptions(model_info.recipe, config_->recipe_options(backend));
363-
RecipeOptions effective_options = options.inherit(model_info.recipe_options.inherit(default_opt));
370+
371+
// Resolve settings: load overrides take precedence over persisted per-model
372+
// options, which take precedence over runtime defaults.
373+
RecipeOptions effective_options = options.inherit(current_model_options.inherit(default_opt));
364374

365375
// LOAD SERIALIZATION STRATEGY (from spec: point #2 in Additional Considerations)
366376
std::unique_lock<std::mutex> lock(load_mutex_);

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)