Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion tools/server/server-models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1518,7 +1518,13 @@ void server_models_routes::init_routes() {
return res;
}
if (!model->is_running()) {
res_err(res, format_error_response("model is not running", ERROR_TYPE_INVALID_REQUEST));
// Idempotent unload: the model exists but is already stopped (LRU-evicted,
// idle-exited, or crashed), so the caller's target state is already met.
// Return success instead of a 400 so clients (e.g. the heierchat webui)
// treat unload as a no-op rather than surfacing a spurious "Failed to
// unload" when their /v1/models snapshot raced an eviction. 400 is now
// reserved for a genuinely unknown model (no meta, handled above).
res_ok(res, {{"success", true}, {"already_unloaded", true}});
return res;
}
models.unload(model->name);
Expand Down
30 changes: 30 additions & 0 deletions tools/server/tests/unit/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,36 @@ def test_router_unload_model():
_wait_for_model_status(model_id, {"unloaded"})


def test_router_unload_already_unloaded_is_idempotent():
"""Unloading a model that is not currently running must be a no-op success,
not a 400. The router evicts models on its own (LRU / idle exit), so a client
whose /v1/models snapshot is stale would otherwise get a spurious
'Failed to unload model'. Genuine unknown models still 400."""
global server
server.start()
model_id = "ggml-org/tinygemma3-GGUF:Q8_0"

_load_model_and_wait(model_id)

# First unload actually stops the running instance.
first = server.make_request("POST", "/models/unload", data={"model": model_id})
assert first.status_code == 200
assert first.body.get("success") is True
_wait_for_model_status(model_id, {"unloaded"})

# Second unload of the now-stopped model is an idempotent no-op success.
second = server.make_request("POST", "/models/unload", data={"model": model_id})
assert second.status_code == 200, f"expected idempotent 200, got {second.status_code}: {second.body}"
assert second.body.get("success") is True
assert second.body.get("already_unloaded") is True

# A genuinely unknown model still returns 400 invalid_request.
unknown = server.make_request("POST", "/models/unload", data={"model": "non-existent/model"})
assert unknown.status_code == 400
err = unknown.body.get("error", {}) if isinstance(unknown.body, dict) else {}
assert "not found" in (err.get("message") or "").lower()


def test_router_models_max_evicts_lru():
global server
server.models_max = 2
Expand Down
Loading