@@ -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+
11751229void 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) {
18511905void 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+
18601927std::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
0 commit comments