diff --git a/README.md b/README.md index 8a34be3..1f463ee 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,10 @@ The Available downloads controls support: Popularity and date ordering use upstream metadata only; the plugin does not invent popularity, dates, RAM requirements, or token metrics. -## Per-model placement +## Staged multi-model loading + +A single **Load selected permanently** action now loads models in verified stages. RAM-only models are attempted first, followed by GPU + RAM models. The backend checks Ollama `/api/ps` after each runner starts and automatically performs one recovery pass for any selected model Ollama evicted. Already resident models are not reloaded. The result includes a retry count and reports when automatic eviction recovery completed, so users do not need to click the load action again manually. + Each installed model in the Model pool has a persistent placement selector: diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 17574b4..fbc4b90 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -336,7 +336,7 @@ if (endpoint === "/models/load") { var resident = Array.isArray(result.resident) ? result.resident : ((result.runtime && result.runtime.model_memory) || []).map(function (item) { return item.name; }); var missing = Array.isArray(result.not_resident) ? result.not_resident : requested.filter(function (name) { return resident.indexOf(name) < 0; }); - setNotice(missing.length ? { warning: "Ollama kept resident: " + resident.join(", ") + ". Not resident: " + missing.join(", ") + ". This is an Ollama scheduler/capacity warning, not a plugin error." } : { ok: "Loaded and resident: " + resident.join(", ") }); + setNotice(missing.length ? { warning: "Ollama kept resident: " + resident.join(", ") + ". Not resident: " + missing.join(", ") + ". This is an Ollama scheduler/capacity warning, not a plugin error." } : { ok: result.message || ("Loaded and resident: " + resident.join(", ")) }); } else { setNotice({ ok: label + ": " + requested.join(", ") }); } diff --git a/dashboard/manifest.json b/dashboard/manifest.json index 0b352d7..812fd4b 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -3,7 +3,7 @@ "label": "Ollama Models", "description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.", "icon": "Cpu", - "version": "1.5.15", + "version": "1.5.16", "tab": {"path": "/ollama-manager", "position": "after:models"}, "entry": "dist/index.js", "css": "dist/style.css", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 88eef5c..134e2c8 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -1446,21 +1446,29 @@ def models_load(body: ModelsRequest) -> dict[str, Any]: if not names: raise HTTPException(400, "Select at least one model to load") placements = {name: _model_placement(body.placements.get(name)) for name in names} - results = [] - for name in names: - _model_load_update(name, active=True, state="queued", stage="Waiting for Ollama", started_at=time.time(), error="", placement=placements[name]) - for name in names: - placement = placements[name] - stage = "Loading into GPU + RAM" if placement == "gpu_ram" else "Loading into system RAM only" - _model_load_update(name, state="loading", stage=stage) - try: - results.append({"name": name, "placement": placement, "ok": True, "result": _load_model(name, placement)}) - _model_load_update(name, state="checking", stage="Checking Ollama resident state") - except Exception as exc: - results.append({"name": name, "placement": placement, "ok": False, "error": str(exc)}) - _model_load_update(name, active=False, state="failed", stage="Ollama load failed", finished_at=time.time(), error=str(exc)) + # Load RAM-only models first, then GPU+RAM models. This avoids asking the + # GPU scheduler to rearrange an already GPU-resident runner unnecessarily. + ordered_names = sorted(names, key=lambda name: 0 if placements[name] == "ram_only" else 1) + results_by_name: dict[str, dict[str, Any]] = {} + for pass_index in range(2): + resident_now = {str(row.get("name") or row.get("model")) for row in _local_ps()} + missing_now = [name for name in ordered_names if name not in resident_now] + if not missing_now: + break + for name in missing_now: + placement = placements[name] + stage = "Loading into GPU + RAM" if placement == "gpu_ram" else "Loading into system RAM only" + _model_load_update(name, active=True, state="loading", stage=stage + " ยท pass " + str(pass_index + 1), attempt=pass_index + 1) + try: + result = _load_model(name, placement) + results_by_name[name] = {"name": name, "placement": placement, "ok": True, "result": result, "attempts": pass_index + 1} + _model_load_update(name, state="checking", stage="Checking Ollama resident state", attempt=pass_index + 1) + except Exception as exc: + results_by_name[name] = {"name": name, "placement": placement, "ok": False, "error": str(exc), "attempts": pass_index + 1} + _model_load_update(name, active=False, state="failed", stage="Ollama load failed", finished_at=time.time(), error=str(exc), attempt=pass_index + 1) resident_rows = _local_ps() resident_names = {str(row.get("name") or row.get("model")) for row in resident_rows} + results = [results_by_name.get(name, {"name": name, "placement": placements[name], "ok": name in resident_names, "attempts": 0}) for name in names] for item in results: item["resident"] = item["name"] in resident_names not_resident = [name for name in names if name not in resident_names] @@ -1470,16 +1478,20 @@ def models_load(body: ModelsRequest) -> dict[str, Any]: elif item["ok"]: _model_load_update(item["name"], active=False, state="evicted", stage="Ollama did not retain this model", finished_at=time.time()) runtime = _runtime_snapshot() + retry_count = sum(max(0, int(item.get("attempts") or 0) - 1) for item in results) return { "ok": bool(results) and not not_resident and all(item["ok"] for item in results), "requested": names, "resident": sorted(resident_names), "not_resident": not_resident, "results": results, + "retries": retry_count, "runtime": runtime, "keep_alive": "permanent", "message": ( - "All selected models are resident." + "All selected models are resident. Automatic Ollama eviction recovery completed." + if not not_resident and retry_count + else "All selected models are resident." if not not_resident else "Ollama did not keep every selected model resident; see not_resident." ), diff --git a/plugin.yaml b/plugin.yaml index dcbfc8e..ab401fe 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: ollama-manager -version: 1.5.15 +version: 1.5.16 description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry. auto_install_dependencies: true python_dependencies: