@@ -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+
11751273void 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) {
18511949void 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+
18601971std::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
0 commit comments