Skip to content

Commit 8d30389

Browse files
jeremyfowersclaude
andcommitted
fix(api): stop rejecting merge_args, and cover the round-trip
Seventh review pass: - Delegating to the config validator caught merge_args in its `*_args` rule, which demands a string, so no value of merge_args could be saved. Only backend-descriptor options have a global-config counterpart, so only those are delegated now. - Negative values are rejected for float options too, not just integers. - DELETE resets the pin whether or not one was saved; a request-scoped /load can pin a process without writing anything. - save_model_options rolls back a failed write like its sibling. - RecipeOptions::inherit reads merge_args defensively; a hand-edited non-boolean took down every read of the model. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4ef922a commit 8d30389

5 files changed

Lines changed: 87 additions & 15 deletions

File tree

src/cpp/include/lemon/recipe_options.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ class RecipeOptions {
3131
/// Option names this recipe accepts, in declaration order.
3232
static std::vector<std::string> keys_for_recipe(const std::string& recipe);
3333

34+
/// True when `key` comes from the recipe's backend descriptor rather than
35+
/// the universal kit (ctx_size, merge_args, the eviction options, pinned).
36+
/// Only the former have a matching key in the global config.
37+
static bool is_backend_option(const std::string& recipe, const std::string& key);
38+
3439
/// True for values the constructor drops as "not set": null, -1, "" and
3540
/// "auto". ctx_size is the exception — every non-number is dropped there,
3641
/// since -1 is the storable value meaning "size the context automatically".

src/cpp/server/model_manager.cpp

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,15 +1585,34 @@ void ModelManager::save_model_options(const ModelInfo& info) {
15851585
LOG(INFO, "ModelManager") << "Saving options for model: " << info.model_name << std::endl;
15861586
// Persist under canonical ID (built-ins are keyed bare in cache but
15871587
// recipe_options.json stores them as builtin.<name>).
1588+
const std::string id = cache_key_to_canonical_id(info.model_name);
15881589
std::lock_guard<std::mutex> write_lock(recipe_options_write_mutex_);
1590+
15891591
json snapshot;
1592+
json previous;
1593+
bool had_previous = false;
15901594
{
15911595
std::lock_guard<std::mutex> lock(models_cache_mutex_);
1592-
recipe_options_[cache_key_to_canonical_id(info.model_name)] = info.recipe_options.to_json();
1596+
had_previous = recipe_options_.contains(id);
1597+
if (had_previous) previous = recipe_options_[id];
1598+
recipe_options_[id] = info.recipe_options.to_json();
15931599
snapshot = recipe_options_;
15941600
update_model_options_in_cache_locked(info);
15951601
}
1596-
save_user_json(get_recipe_options_file(), snapshot);
1602+
1603+
try {
1604+
save_user_json(get_recipe_options_file(), snapshot);
1605+
} catch (...) {
1606+
// Keep memory matching disk, as write_saved_model_options does.
1607+
std::lock_guard<std::mutex> lock(models_cache_mutex_);
1608+
if (had_previous) {
1609+
recipe_options_[id] = previous;
1610+
} else {
1611+
recipe_options_.erase(id);
1612+
}
1613+
cache_valid_ = false;
1614+
throw;
1615+
}
15971616
}
15981617

15991618
json ModelManager::get_saved_model_options(const std::string& model_name) {

src/cpp/server/recipe_options.cpp

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ std::vector<std::string> RecipeOptions::keys_for_recipe(const std::string& recip
109109
return get_keys_for_recipe(recipe);
110110
}
111111

112+
bool RecipeOptions::is_backend_option(const std::string& recipe, const std::string& key) {
113+
const auto* desc = lemon::backends::descriptor_for(recipe);
114+
if (!desc) return false;
115+
for (const auto& opt : desc->options) {
116+
if (opt.name == key) return true;
117+
}
118+
return false;
119+
}
120+
112121
bool RecipeOptions::is_default_sentinel(const std::string& key, const json& value) {
113122
return is_empty_option(key, value);
114123
}
@@ -202,7 +211,11 @@ std::string RecipeOptions::to_log_string(bool resolve_defaults) const {
202211

203212
RecipeOptions RecipeOptions::inherit(const RecipeOptions& options) const {
204213
json merged = options_;
205-
bool merge_args = options_.contains("merge_args") ? options_["merge_args"].get<bool>() : options.get_option("merge_args").get<bool>();
214+
// Read defensively: a hand-edited recipe_options.json can hold any type
215+
// here, and throwing would take down every read of the model.
216+
const json own_merge_args = options_.contains("merge_args") ? options_["merge_args"]
217+
: options.get_option("merge_args");
218+
bool merge_args = own_merge_args.is_boolean() ? own_merge_args.get<bool>() : true;
206219

207220
for (auto it = options.options_.begin(); it != options.options_.end(); ++it) {
208221
if (merge_args && it.key().size() >= 5 && it.key().substr(it.key().size() - 5) == "_args") {

src/cpp/server/server.cpp

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2809,18 +2809,20 @@ static std::string validate_option_value(const RuntimeConfig& config,
28092809
// A fractional value for a whole-number option is silently ignored by the
28102810
// consumers that read it, and a negative one is meaningless for all of
28112811
// them, so refuse both.
2812-
if (expected.is_number_integer()) {
2813-
if (!value.is_number_integer()) {
2814-
return "'" + key + "' must be a whole number";
2815-
}
2816-
if (value.get<int64_t>() < 0) {
2817-
return "'" + key + "' cannot be negative";
2818-
}
2812+
if (expected.is_number_integer() && !value.is_number_integer()) {
2813+
return "'" + key + "' must be a whole number";
2814+
}
2815+
if (expected.is_number() && value.is_number() && value.get<double>() < 0) {
2816+
return "'" + key + "' cannot be negative";
28192817
}
28202818

28212819
// Defer to the validator the global config already uses for these same
2822-
// option names, so the two surfaces cannot disagree about a value. Keys it
2823-
// does not know about (the eviction options, *_args) fall through.
2820+
// option names, so the two surfaces cannot disagree about a value. Only
2821+
// descriptor options have a global-config counterpart; the universal kit
2822+
// must not be routed through it, since key names like merge_args collide
2823+
// with the config's own rules.
2824+
if (!RecipeOptions::is_backend_option(recipe, key)) return "";
2825+
28242826
std::string config_key = key;
28252827
const std::string recipe_prefix = recipe + "_";
28262828
if (config_key.rfind(recipe_prefix, 0) == 0) {
@@ -2983,9 +2985,10 @@ void Server::handle_model_options_delete(const httplib::Request& req, httplib::R
29832985
respond_with_model_options(req, res,
29842986
[this](const std::string& model_key, const ModelInfo&, httplib::Response&,
29852987
bool& touched_pinned) {
2986-
const nlohmann::json saved = model_manager_->get_saved_model_options(model_key);
2987-
touched_pinned = saved.contains("pinned");
2988-
if (!saved.empty()) {
2988+
// Resetting to defaults covers the pin whether or not one was saved:
2989+
// a request-scoped /load may have pinned the live process instead.
2990+
touched_pinned = true;
2991+
if (!model_manager_->get_saved_model_options(model_key).empty()) {
29892992
model_manager_->set_saved_model_options(model_key, nlohmann::json::object());
29902993
}
29912994
return true;

test/server_endpoints.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,38 @@ def live_pinned():
13461346

13471347
print("[OK] pinned changes are applied to the running model")
13481348

1349+
def test_012x_every_effective_option_can_be_saved_back(self):
1350+
"""Posting the reported `effective` object back whole is accepted.
1351+
1352+
The response advertises `effective` as the set of names POST accepts, so
1353+
any key the endpoint reports but refuses is a contradiction. This caught
1354+
`merge_args`, whose name collides with the global config's `*_args` rule.
1355+
"""
1356+
self.addCleanup(self._reset_options)
1357+
self._reset_options()
1358+
1359+
effective = requests.get(self._options_url(), timeout=TIMEOUT_DEFAULT).json()[
1360+
"effective"
1361+
]
1362+
response = requests.post(
1363+
self._options_url(), json=effective, timeout=TIMEOUT_DEFAULT
1364+
)
1365+
self.assertEqual(
1366+
response.status_code,
1367+
200,
1368+
f"Every reported option must be settable, got {response.text}",
1369+
)
1370+
1371+
# merge_args specifically: a boolean, and both values have to round-trip.
1372+
for value in (True, False):
1373+
saved = requests.post(
1374+
self._options_url(), json={"merge_args": value}, timeout=TIMEOUT_DEFAULT
1375+
)
1376+
self.assertEqual(saved.status_code, 200, saved.text)
1377+
self.assertEqual(saved.json()["saved"]["merge_args"], value)
1378+
1379+
print("[OK] Every option reported in `effective` can be saved back")
1380+
13491381
def test_012w_options_reject_values_the_global_config_rejects(self):
13501382
"""Per-model options are held to the same value rules as global config.
13511383

0 commit comments

Comments
 (0)