diff --git a/README.md b/README.md index 7fee46d..18c0b5b 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,6 @@ The full validation scan covered 7,230 raw public variants: 604 were classified Available downloads now combine Ollama's popular API response with the public `https://ollama.com/library` index and each public family tag page. This means models that are not currently popular and are not installed locally—such as `ornith-1.5:9b` and `ornith-1.5:35b`—are discoverable. The catalog refresh found 235 public family slugs and 7,230 raw variants during validation. The existing MLX exclusion and host-RAM fit filter still apply, so very large variants such as `ornith-1.5:397b` remain hidden when they cannot fit the detected host RAM. -Model search can optionally query the public Hugging Face Hub API when **Search Hugging Face** is checked and the query contains at least two characters. Hugging Face results are labeled **Hugging Face**, show repository metadata such as pipeline, library, downloads, and likes, and include an **Open on Hugging Face** link. Repositories with GGUF files also show **Download GGUF**. The picker groups split GGUF shards into one complete selectable set, downloads all required files, checks available disk space, and imports the first shard into local Ollama with `ollama create` when Ollama shares the dashboard filesystem. Transformers/safetensors/FP8-only repositories remain viewable but are not falsely offered as Ollama downloads. For example, `Qwen/Qwen3.8-Flash-Next` is searchable, while `unsloth/Qwen3.8-Flash-Next-GGUF` exposes complete GGUF sets. - The Live runtime panel now shows overall CPU usage, logical CPU count, load averages, overall GPU utilization, and per-GPU VRAM usage. When multiple logical CPUs are detected, it expands into a scrollable responsive per-core grid. When multiple GPUs are detected, it expands into a responsive per-GPU grid showing utilization, VRAM used/free, temperature, and power when the driver reports them. The grids use auto-fit sizing and bounded scrolling so the panel scales to larger CPU and GPU counts without overflowing the dashboard. @@ -158,9 +156,7 @@ The dashboard plugin API is mounted when the dashboard starts. Restart Hermes af ## Download storage -The plugin does not store normal Ollama model blobs in Hermes. It sends Ollama's native `POST /api/pull` request to the selected endpoint. Therefore a normal Ollama download goes to the Ollama instance shown in the job message, and the Ollama service owns the model storage location. The exact path is controlled by Ollama's `OLLAMA_MODELS` setting; common Linux service/user locations are `/usr/share/ollama/.ollama/models` and `~/.ollama/models`. Check the Ollama service environment on the target host to determine the authoritative path. - -Hugging Face downloads are separate: the checkbox enables Hub search, and **Download GGUF** downloads a selected complete GGUF file or shard set under the Hermes home `huggingface///` directory. The plugin revalidates the repository metadata and filename, checks free disk space when the Hub publishes sizes, and reports progress in the jobs panel. On a non-containerized host where Ollama is on the same filesystem, it writes a temporary Modelfile and runs `ollama create` to import the GGUF. If Ollama is remote or containerized with a different filesystem, the file is downloaded but automatic import is not attempted; the UI says so explicitly. Transformers, safetensors, and FP8-only repositories remain browseable through their Hugging Face link but are not treated as Ollama-compatible downloads. +The plugin does not store model blobs in Hermes. It sends Ollama's native `POST /api/pull` request to the selected endpoint. Therefore a download goes to the Ollama instance shown in the job message, and the Ollama service owns the model storage location. The exact path is controlled by Ollama's `OLLAMA_MODELS` setting; common Linux service/user locations are `/usr/share/ollama/.ollama/models` and `~/.ollama/models`. Check the Ollama service environment on the target host to determine the authoritative path. ## Security limits diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index c59660c..0b4014f 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -126,10 +126,9 @@ } function ModelCard(props) { - var model = props.model, installed = props.installed, busy = props.busy, action = props.action, onHuggingFace = props.onHuggingFace; + var model = props.model, installed = props.installed, busy = props.busy, action = props.action; var openState = React.useState(false), open = openState[0], setOpen = openState[1]; var badges = []; - if (model.source === "huggingface") badges.push(h(Badge, { key: "huggingface", tone: "popular" }, "Hugging Face")); if (installed && model.loaded) badges.push(h(Badge, { key: "loaded", tone: "live" }, "loaded")); if (installed) badges.push(h(Badge, { key: "installed", tone: "installed" }, "installed")); if (!installed) badges.push(h(Badge, { key: "available", tone: "download" }, "available")); @@ -142,9 +141,7 @@ h("div", { className: "ollama-card-actions" }, installed && h(Button, { disabled: !!busy, onClick: function () { action("redownload", model.name); } }, busy === model.name + ":redownload" ? "Updating…" : "Update / re-download"), installed && h(Button, { disabled: !!busy, className: "ollama-button danger", onClick: function () { action("delete", model.name); } }, busy === model.name + ":delete" ? "Removing…" : "Remove"), - !installed && model.source === "huggingface" && model.hf_url && h("a", { className: "ollama-button secondary", href: model.hf_url, target: "_blank", rel: "noreferrer" }, "Open on Hugging Face"), - !installed && model.source === "huggingface" && onHuggingFace && h(Button, { disabled: !!busy, onClick: function () { onHuggingFace(model); } }, "Download GGUF"), - !installed && model.source !== "huggingface" && h(Button, { disabled: !!busy, onClick: function () { action("pull", model.name); } }, busy === model.name + ":pull" ? "Downloading…" : "Download") + !installed && h(Button, { disabled: !!busy, onClick: function () { action("pull", model.name); } }, busy === model.name + ":pull" ? "Downloading…" : "Download") ) ), h("div", { className: "ollama-model-summary" }, @@ -153,7 +150,7 @@ h("div", null, h("small", null, "Type"), h("strong", null, model.architecture || "Unknown")), h("div", null, h("small", null, "Parameters"), h("strong", null, model.parameter_size || "Unknown"), model.activated_parameter_size && h("small", { className: "ollama-activated-parameters" }, model.activated_parameter_size, " activated")) ), - h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.hf_downloads != null && h("span", null, Number(model.hf_downloads).toLocaleString() + " downloads"), model.hf_likes != null && h("span", null, Number(model.hf_likes).toLocaleString() + " likes"), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), model.modified_at ? h("span", null, "Last updated " + fmtDate(model.modified_at)) : h("span", { className: "ollama-date-unavailable" }, "Last updated unavailable")), + h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), model.modified_at ? h("span", null, "Last updated " + fmtDate(model.modified_at)) : h("span", { className: "ollama-date-unavailable" }, "Last updated unavailable")), h("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")), installed && h(VariantTable, { model: model, action: action, busy: busy }), h(Button, { className: "ollama-details-toggle", onClick: function () { setOpen(!open); } }, open ? "Hide capability breakdown" : "Show capability breakdown"), @@ -162,7 +159,6 @@ h("h4", null, "Runtime estimate"), h("p", null, model.expected_ram_basis || "No estimate basis available.", " Actual memory varies with context length, KV cache, GPU offload, and concurrent requests."), h("div", { className: "ollama-detail-grid" }, - h("span", null, "Source: ", h("strong", null, model.source_label || model.source || "Ollama")), h("span", null, "Family: ", h("strong", null, model.family || "unknown")), h("span", null, "Digest: ", h("strong", null, model.digest ? model.digest.slice(0, 16) + "…" : "unknown")), h("span", null, "Embedding: ", h("strong", null, model.embedding_length || "unknown")), @@ -511,30 +507,13 @@ var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1]; var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[1]; - var hfFileDialogState = React.useState(null), hfFileDialog = hfFileDialogState[0], setHfFileDialog = hfFileDialogState[1]; - var hfSearchState = React.useState(false), includeHuggingFace = hfSearchState[0], setIncludeHuggingFace = hfSearchState[1]; var loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1]; - var externalSearchState = React.useState([]), externalSearch = externalSearchState[0], setExternalSearch = externalSearchState[1]; - var externalSearchBusyState = React.useState(false), externalSearchBusy = externalSearchBusyState[0], setExternalSearchBusy = externalSearchBusyState[1]; var loadSequence = React.useRef(0); - var searchSequence = React.useRef(0); function load() { var sequence = ++loadSequence.current; return fetchJSON(API + "/status").then(function (value) { if (sequence !== loadSequence.current) return value; setData(value); setLoading(false); return value; }).catch(function (err) { if (sequence === loadSequence.current) { setNotice({ error: err.message || String(err) }); setLoading(false); } }); } React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []); - React.useEffect(function () { - var term = query.trim(); - var sequence = ++searchSequence.current; - if (term.length < 2 || !includeHuggingFace) { setExternalSearch([]); setExternalSearchBusy(false); return; } - setExternalSearchBusy(true); - var timer = setTimeout(function () { - fetchJSON(API + "/catalog/search?q=" + encodeURIComponent(term) + "&limit=30&include_huggingface=true").then(function (value) { - if (sequence === searchSequence.current) setExternalSearch(value.huggingface_results || []); - }).catch(function () { if (sequence === searchSequence.current) setExternalSearch([]); }).finally(function () { if (sequence === searchSequence.current) setExternalSearchBusy(false); }); - }, 250); - return function () { clearTimeout(timer); }; - }, [query, includeHuggingFace]); function action(kind, name, selectedTarget) { if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; if ((kind === "pull" || kind === "redownload") && !selectedTarget) { @@ -547,16 +526,6 @@ fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, target: selectedTarget || "local" }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); } function refreshCatalog() { setBusy("catalog"); setNotice(null); fetchJSON(API + "/catalog/refresh", { method: "POST" }).then(function (result) { setNotice({ ok: "Catalog refreshed: " + result.count + " models." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); } - function openHuggingFaceFiles(model) { - if (!model || !model.name) return; - setBusy("hf-files"); setNotice(null); setHfFileDialog({ model: model, files: null, import_supported: false }); - fetchJSON(API + "/huggingface/files?repo_id=" + encodeURIComponent(model.name)).then(function (value) { setHfFileDialog({ model: model, files: value.files || [], import_supported: !!value.import_supported }); }).catch(function (err) { setHfFileDialog(null); setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); - } - function downloadHuggingFaceFile(model, file) { - if (!model || !file || !file.filename) return; - setBusy("hf-download"); setNotice(null); - fetchJSON(API + "/huggingface/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo_id: model.name, filename: file.filename }) }).then(function (result) { setHfFileDialog(null); setNotice({ ok: result.message || "Hugging Face GGUF download started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); - } var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? (showOversized ? data.catalog_all || data.catalog || [] : data.catalog || []) : []) : []; var models = baseModels; if (tab === "catalog") { @@ -574,10 +543,6 @@ if (catalogSort === "name") return String(a.name).localeCompare(String(b.name)); return (Number(a.popularity_rank) || 999999) - (Number(b.popularity_rank) || 999999); }); - if (query.trim().length >= 2 && externalSearch.length) { - var existingNames = new Set(models.map(function (model) { return model.name; })); - models = models.concat(externalSearch.filter(function (model) { return !existingNames.has(model.name); })); - } } var needle = query.toLowerCase().trim(); if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; }); var catalogCapabilities = data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : []; @@ -603,20 +568,17 @@ h("label", { className: "ollama-catalog-checkbox ollama-catalog-memory-bypass", title: "This only bypasses the catalog display filter; loading remains protected by the 95% RAM safety guard." }, h("input", { type: "checkbox", checked: showOversized, onChange: function (event) { setShowOversized(event.target.checked); } }), h("span", null, "Show models above estimated RAM")) ); var browseToolbar = tab !== "chat" && h("div", { className: "ollama-browse-row" }, - h("input", { className: "ollama-search", value: query, placeholder: includeHuggingFace ? "Search Ollama + Hugging Face models…" : "Search Ollama models…", onChange: function (event) { setQuery(event.target.value); } }), - tab === "catalog" && h("label", { className: "ollama-catalog-checkbox ollama-huggingface-checkbox", title: "Search the public Hugging Face Hub in addition to Ollama." }, h("input", { type: "checkbox", checked: includeHuggingFace, onChange: function (event) { setIncludeHuggingFace(event.target.checked); } }), h("span", null, "Search Hugging Face")), - externalSearchBusy && h("small", { className: "ollama-search-status" }, "Searching Hugging Face…"), + h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }), catalogControls ); return h("main", { className: "ollama-page" }, h("header", { className: "ollama-hero" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, chat with, download, update, and remove models from the local Ollama runtime.")), h("div", { className: "ollama-health" }, h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"), data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")), h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog")), h(ConnectionPanel, { data: data, reload: load })), targetDialog && h("div", { className: "ollama-target-modal" }, h("div", { className: "ollama-target-card" }, h("h3", null, "Where should " + targetDialog.name + " be downloaded?"), h("p", null, "Both local and remote Ollama instances are online. Choose the destination for this model."), targetDialog.targets.map(function (item) { return h(Button, { key: item.kind, onClick: function () { var chosen = targetDialog; setTargetDialog(null); action(chosen.kind, chosen.name, item.kind); } }, (item.kind || "local").toUpperCase(), " · ", item.url, " · v", item.version, " · ", item.models, " models"); }), h(Button, { className: "secondary", onClick: function () { setTargetDialog(null); } }, "Cancel"))), - hfFileDialog && h("div", { className: "ollama-target-modal" }, h("div", { className: "ollama-target-card ollama-huggingface-file-card" }, h("h3", null, "Download GGUF from ", hfFileDialog.model.name), h("p", null, hfFileDialog.import_supported ? "Choose a GGUF file. It will be downloaded and imported into the local Ollama runtime." : "Choose a GGUF file. It will be downloaded to Hermes storage; automatic Ollama import is unavailable in this deployment."), hfFileDialog.files === null ? h("p", null, "Loading repository files…") : hfFileDialog.files.length ? hfFileDialog.files.map(function (file) { return h("div", { className: "ollama-huggingface-file-row", key: file.filename }, h("span", null, h("strong", null, file.filename), h("small", null, (file.size_label || "Unknown size") + (file.split ? " · complete " + file.file_count + "-shard set" : ""))), h(Button, { disabled: !!busy, onClick: function () { downloadHuggingFaceFile(hfFileDialog.model, file); } }, busy === "hf-download" ? "Starting…" : (file.split ? "Download set" : "Download"))); }) : h("p", null, "This repository has no compatible GGUF files. Open the repository to inspect Transformers, safetensors, or other formats."), h("div", { className: "ollama-huggingface-file-actions" }, h("a", { className: "ollama-button secondary", href: hfFileDialog.model.hf_url, target: "_blank", rel: "noreferrer" }, "Open repository"), h(Button, { className: "secondary", onClick: function () { setHfFileDialog(null); } }, "Cancel")))), notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok), h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-nav-row" }, navTabs, h("div", { className: "ollama-toolbar-disk" }, h("span", null, "Disk"), h("strong", null, disk.used_percent == null ? "n/a" : Number(disk.used_percent).toFixed(1) + "%"), h("small", null, disk.available ? fmtBytes(disk.free_bytes) + " free" : "Unavailable"))), browseToolbar), tab !== "chat" && h("div", { className: "ollama-info-strip" }, h("span", null, data && data.models ? data.models.filter(function (m) { return m.loaded; }).length + " currently loaded" : "Loading runtime state…"), h("span", null, "Catalog checked " + (data && data.catalog_updated_at ? fmtDate(data.catalog_updated_at) : "not yet")), h("span", null, "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")), tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [], refresh: load }), tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below the detected system RAM (" + (data && data.popular_filter && data.popular_filter.max_expected_ram_gib ? data.popular_filter.max_expected_ram_gib + " GiB" : "detecting…") + "). Oversized families are represented by a smaller fitting variant when available."), jobs.length > 0 && h("section", { className: "ollama-jobs" }, jobs.map(function (job) { return h("div", { key: job.id }, h("strong", null, job.action + " · " + job.name + " · " + (job.target || "local") + (job.endpoint ? " · " + job.endpoint : "")), h("span", null, job.percent == null ? job.status : job.percent + "%")); })), - tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : query.trim().length >= 2 ? "No Ollama or Hugging Face models matched this search." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.source + ":" + model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action, onHuggingFace: openHuggingFaceFiles }); })) + tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); })) ); } registry.register("ollama-manager", Page); diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css index 3e56361..d145de8 100644 --- a/dashboard/dist/style.css +++ b/dashboard/dist/style.css @@ -8,7 +8,7 @@ @media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:0;max-width:620px;width:100%;box-sizing:border-box;container-type:inline-size;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:6px;align-items:stretch;margin-top:9px}.ollama-connection-role,.ollama-connection-input{box-sizing:border-box;min-width:0;width:100%;height:36px;border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{overflow:hidden;text-overflow:ellipsis}.ollama-connection-form .ollama-button{height:36px;min-width:0;padding:7px 9px;white-space:nowrap}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-connection-row-main{display:flex;align-items:center;gap:7px;flex:1;min-width:0;border:0;padding:0;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}.ollama-connection-remove{flex:0 0 auto;padding:5px 7px;font-size:9px}.ollama-connection-row-main>span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row-main strong{font-size:10px}.ollama-connection-row-main small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere} @container (max-width: 460px){.ollama-connection-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.ollama-connection-role,.ollama-connection-input{grid-column:span 1}.ollama-connection-form .ollama-button{width:100%}} -.ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}.ollama-huggingface-file-card{max-height:min(720px,90vh);overflow:auto}.ollama-huggingface-file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 0;border-top:1px solid rgba(164,211,199,.14)}.ollama-huggingface-file-row span{display:grid;gap:3px;min-width:0}.ollama-huggingface-file-row strong{overflow-wrap:anywhere;font-size:12px}.ollama-huggingface-file-row small,.ollama-search-status{color:#8fa9a4;font-size:11px}.ollama-huggingface-file-actions{display:flex;gap:8px;flex-wrap:wrap}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}} +.ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}} .ollama-catalog-controls{display:grid;grid-template-columns:repeat(3,minmax(130px,1fr));gap:8px;align-items:end;margin-top:0;padding:10px;border:1px solid rgba(164,211,199,.16);border-radius:10px;background:rgba(10,31,28,.55)}.ollama-catalog-controls label{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-catalog-checkbox{display:flex!important;flex-direction:row!important;align-items:center;gap:8px;grid-column:1 / -1;padding:8px 4px;color:#b8ead9!important;text-transform:none!important;letter-spacing:normal!important;cursor:pointer}.ollama-catalog-checkbox input{width:15px;height:15px;margin:0;accent-color:#75d2b7}.ollama-catalog-checkbox span{font-size:11px}.ollama-catalog-memory-bypass{color:#ffd89a!important;background:rgba(142,90,25,.12);border-radius:7px}.ollama-catalog-select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal} @media(max-width:1000px){.ollama-nav-row{display:grid;grid-template-columns:1fr}.ollama-toolbar-disk{justify-self:end}.ollama-browse-row{grid-template-columns:1fr}.ollama-catalog-controls{margin-top:0}} @media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}}.ollama-harness-primary,.ollama-harness-validators{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.ollama-harness-primary{min-width:260px}.ollama-harness-primary label{display:flex;align-items:center;gap:8px;color:#a5bfba;font-size:11px}.ollama-harness-primary select{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;max-width:260px}.ollama-harness-validators{flex-basis:100%;padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}.ollama-harness-validators>strong{color:#d5e8e2;font-size:11px}.ollama-harness-ready,.ollama-harness-warning{flex-basis:100%;font-size:10px}.ollama-harness-ready{color:#9af1c7}.ollama-harness-warning{color:#ffd89a}.ollama-validation-evidence{margin-top:10px;padding:10px 12px;border:1px solid rgba(141,210,193,.22);border-radius:8px;background:rgba(10,31,28,.5);color:#a5bfba;font-size:11px}.ollama-validation-evidence summary{cursor:pointer;color:#b8ead9;font-weight:700}.ollama-validation-report{margin-top:10px;padding-top:8px;border-top:1px solid rgba(164,211,199,.12)}.ollama-validation-report strong{color:#effcf8;font-size:11px}.ollama-validation-report p{margin:4px 0 0;white-space:pre-wrap;line-height:1.45}.ollama-storage-panel{margin-top:14px;padding:14px 16px;border:1px solid rgba(141,210,193,.22);border-radius:10px;background:rgba(10,31,28,.5)}.ollama-storage-copy{display:flex;flex-direction:column;gap:4px;margin-top:10px}.ollama-storage-copy strong{color:#effcf8;font-size:12px}.ollama-storage-copy small,.ollama-storage-note{color:#a5bfba;font-size:10px}.ollama-storage-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.ollama-storage-note{display:block;margin-top:10px} diff --git a/dashboard/manifest.json b/dashboard/manifest.json index dbcee03..f5cb822 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.7.4", + "version": "1.7.5", "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 1cc2650..459d59e 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -70,9 +70,6 @@ def _discover_ollama_endpoint() -> None: LOCAL_OLLAMA = _ollama_base_url() REMOTE_OLLAMA = "https://ollama.com" -HUGGINGFACE_API = "https://huggingface.co/api" -HUGGINGFACE_DOWNLOAD_ROOT = "huggingface" -HF_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") CATALOG_FILE = "catalog.json" MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$") MELBOURNE = ZoneInfo("Australia/Melbourne") @@ -750,14 +747,6 @@ def _json_request(url: str, method: str = "GET", payload: Any = None, timeout: i return value if isinstance(value, dict) else {} -def _json_list_request(url: str, timeout: int = 30) -> list[dict[str, Any]]: - request = Request(url, headers={"Accept": "application/json", "User-Agent": "Hermes-Ollama-Models/1.7.4"}) - with urlopen(request, timeout=timeout) as response: - raw = response.read() - value = json.loads(raw.decode("utf-8")) if raw else [] - return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else [] - - def _valid_name(name: str) -> str: name = str(name or "").strip() if not MODEL_RE.fullmatch(name): @@ -1372,251 +1361,6 @@ def _model_view(raw: dict[str, Any], loaded: dict[str, Any] | None = None, sourc } -def _huggingface_model_view(raw: dict[str, Any]) -> dict[str, Any]: - repo_id = str(raw.get("id") or raw.get("modelId") or "").strip() - raw_tags = raw.get("tags") - tags = [str(tag).strip() for tag in raw_tags if str(tag).strip()][:32] if isinstance(raw_tags, list) else [] - pipeline = str(raw.get("pipeline_tag") or "").strip() - library = str(raw.get("library_name") or "").strip() - searchable = " ".join([repo_id, pipeline, library, *tags]) - capabilities = _infer_capabilities(repo_id, library, {}, ["completion"] if pipeline in {"text-generation", "text2text-generation", "image-text-to-text"} else []) - if pipeline == "image-text-to-text" and "vision" not in capabilities: - capabilities.append("vision") - if pipeline in {"text-to-image", "image-to-image", "image-classification"} and "vision" not in capabilities: - capabilities.append("vision") - is_moe = bool(re.search(r"(?:moe|mixture.of.experts|a\d+b)", searchable, re.I)) - return { - "name": repo_id, - "source": "huggingface", - "source_label": "Hugging Face", - "downloadable": False, - "installed": False, - "loaded": False, - "size_bytes": 0, - "size_gb": None, - "size_label": "Hub repository", - "loaded_bytes": 0, - "loaded_vram_bytes": 0, - "digest": str(raw.get("sha") or ""), - "modified_at": raw.get("lastModified"), - "family": library or "Hugging Face model", - "architecture": pipeline or "Unknown", - "is_moe": is_moe, - "parameter_size": "unknown", - "activated_parameter_size": None, - "parameter_summary": "unknown", - "description": f"Hugging Face model · {pipeline or 'pipeline unavailable'}" + (f" · {library}" if library else ""), - "quantization": "see repository files", - "format": library or "Hub format", - "context_length": None, - "input_modalities": ["Text", "Image"] if "vision" in capabilities else ["Text"], - "embedding_length": None, - "capabilities": list(dict.fromkeys(capabilities)), - "capability_breakdown": {cap: CAPABILITY_INFO[cap] for cap in capabilities if cap in CAPABILITY_INFO}, - "strengths": [pipeline or "model repository", "Hugging Face Hub metadata"], - "expected_ram_gb": None, - "expected_ram_label": "Unknown · inspect repository requirements", - "expected_ram_basis": "Hugging Face does not provide a reliable universal runtime RAM estimate in search results.", - "hf_url": f"https://huggingface.co/{repo_id}", - "hf_downloads": int(raw.get("downloads") or 0), - "hf_likes": int(raw.get("likes") or 0), - "hf_pipeline_tag": pipeline, - "hf_library": library, - "hf_tags": tags, - } - - -def _search_huggingface(query: str, limit: int = 30) -> list[dict[str, Any]]: - query = str(query or "").strip() - if len(query) < 2: - return [] - limit = max(1, min(int(limit), 50)) - url = f"{HUGGINGFACE_API}/models?{urlencode({'search': query[:120], 'limit': limit, 'sort': 'downloads', 'direction': '-1', 'full': 'false'})}" - try: - rows = _json_list_request(url, timeout=20) - except (HTTPError, URLError, OSError, ValueError): - return [] - return [_huggingface_model_view(row) for row in rows if (row.get("id") or row.get("modelId"))] - - -def _valid_huggingface_repo(repo_id: str) -> str: - repo_id = str(repo_id or "").strip() - if not HF_REPO_RE.fullmatch(repo_id): - raise HTTPException(400, "Invalid Hugging Face repository id") - return repo_id - - -def _valid_huggingface_filename(filename: str) -> str: - filename = unquote(str(filename or "")).strip().replace("\\", "/") - path = Path(filename) - if not filename or path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts) or not filename.lower().endswith(".gguf"): - raise HTTPException(400, "Only safe Hugging Face GGUF filenames are supported") - return filename - - -def _format_bytes(value: int) -> str: - amount = float(max(0, int(value or 0))) - for unit in ("B", "KiB", "MiB", "GiB", "TiB"): - if amount < 1024 or unit == "TiB": - return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B" - amount /= 1024 - return "Unknown" - - -def _huggingface_repo_files(repo_id: str) -> list[dict[str, Any]]: - repo_id = _valid_huggingface_repo(repo_id) - url = f"{HUGGINGFACE_API}/models/{repo_id}?full=true" - try: - metadata = _json_request(url, timeout=30) - except (HTTPError, URLError, OSError, ValueError) as exc: - raise HTTPException(502, "Hugging Face repository metadata is unavailable") from exc - raw_files: list[dict[str, Any]] = [] - siblings = metadata.get("siblings", []) if isinstance(metadata, dict) else [] - for item in siblings if isinstance(siblings, list) else []: - if not isinstance(item, dict): - continue - filename = str(item.get("rfilename") or item.get("path") or "").strip() - if not filename.lower().endswith(".gguf"): - continue - try: - filename = _valid_huggingface_filename(filename) - except HTTPException: - continue - lfs: dict[str, Any] = {} - raw_lfs = item.get("lfs") - if isinstance(raw_lfs, dict): - lfs = raw_lfs - size = int(lfs.get("size") or item.get("size") or 0) - raw_files.append({"filename": filename, "size": size, "download_url": f"https://huggingface.co/{repo_id}/resolve/main/{filename}?download=true"}) - groups: dict[str, list[dict[str, Any]]] = {} - split_pattern = re.compile(r"^(.*)-\d{5}-of-\d{5}(\.gguf)$", re.I) - for item in raw_files: - match = split_pattern.match(item["filename"]) - group_key = f"{match.group(1)}{match.group(2)}" if match else item["filename"] - groups.setdefault(group_key, []).append(item) - files: list[dict[str, Any]] = [] - for group_key, group in groups.items(): - group.sort(key=lambda item: item["filename"]) - size = sum(int(item.get("size") or 0) for item in group) - files.append({ - "filename": group[0]["filename"], - "filenames": [item["filename"] for item in group], - "size": size, - "size_label": _format_bytes(size) if size else (f"{len(group)} shards · size unavailable" if len(group) > 1 else "Unknown"), - "file_count": len(group), - "split": len(group) > 1, - "download_url": group[0]["download_url"], - }) - return sorted(files, key=lambda item: (item.get("size") or 0, item["filename"])) - - -def _hf_ollama_import_available() -> bool: - parsed = urlparse(LOCAL_OLLAMA) - return parsed.hostname in {"localhost", "127.0.0.1", "::1"} and not _running_in_container() and bool(shutil.which("ollama")) - - -def _ollama_model_name_for_hf(repo_id: str, filename: str) -> str: - owner, repo = repo_id.split("/", 1) - stem = re.sub(r"-\d{5}-of-\d{5}$", "", Path(filename).stem.lower()) - value = re.sub(r"[^a-z0-9._-]+", "-", f"hf-{owner}-{repo}-{stem}").strip("-._") - return value[:190] or "hf-imported-model" - - -def _run_huggingface_download(job_id: str, repo_id: str, filename: str) -> None: - temporary: Path | None = None - try: - repo_id = _valid_huggingface_repo(repo_id) - filename = _valid_huggingface_filename(filename) - file_info = next((item for item in _huggingface_repo_files(repo_id) if filename in item.get("filenames", [item["filename"]])), None) - if not file_info: - raise RuntimeError("Requested GGUF file was not found in the public Hugging Face repository") - expected = int(file_info.get("size") or 0) - free_bytes = shutil.disk_usage(_home()).free - if expected and free_bytes < expected + 1024 ** 3: - raise RuntimeError("Insufficient free disk space for the Hugging Face GGUF download") - destination_root = _home() / HUGGINGFACE_DOWNLOAD_ROOT / repo_id - destination_root.mkdir(parents=True, exist_ok=True) - download_files = list(file_info.get("filenames") or [filename]) - completed = 0 - total = expected - if not total: - for remote_filename in download_files: - head_url = f"https://huggingface.co/{repo_id}/resolve/main/{remote_filename}?download=true" - try: - head_request = Request(head_url, method="HEAD", headers={"User-Agent": "Hermes-Ollama-Models/1.7.4"}) - with urlopen(head_request, timeout=30) as head_response: - total += int(head_response.headers.get("Content-Length") or 0) - except (HTTPError, URLError, OSError, ValueError): - continue - free_bytes = shutil.disk_usage(_home()).free - if total and free_bytes < total + 1024 ** 3: - raise RuntimeError("Insufficient free disk space for the complete Hugging Face GGUF download") - for remote_filename in download_files: - destination = destination_root / remote_filename - destination.parent.mkdir(parents=True, exist_ok=True) - temporary = destination.with_name(f".{destination.name}.{job_id}.part") - download_url = f"https://huggingface.co/{repo_id}/resolve/main/{remote_filename}?download=true" - request = Request(download_url, headers={"Accept": "application/octet-stream", "User-Agent": "Hermes-Ollama-Models/1.7.4"}) - with urlopen(request, timeout=60) as response, temporary.open("wb") as output: - total = max(total, completed + int(response.headers.get("Content-Length") or 0)) - for chunk in iter(lambda: response.read(8 * 1024 * 1024), b""): - output.write(chunk) - completed += len(chunk) - _set_job(job_id, status="downloading", completed=completed, total=total, percent=round(completed * 100 / total, 1) if total else None) - os.replace(temporary, destination) - destination = destination_root / download_files[0] - model_name = _ollama_model_name_for_hf(repo_id, filename) - imported = False - if _hf_ollama_import_available(): - modelfile = destination.with_name(f".{destination.name}.Modelfile") - modelfile.write_text(f"FROM {destination}\n", encoding="utf-8") - try: - env = dict(os.environ) - env["OLLAMA_HOST"] = LOCAL_OLLAMA - result = subprocess.run(["ollama", "create", model_name, "-f", str(modelfile)], capture_output=True, text=True, timeout=3600, check=False, env=env) - if result.returncode != 0: - raise RuntimeError((result.stderr or result.stdout or "ollama create failed")[-1000:]) - imported = True - finally: - try: - modelfile.unlink() - except FileNotFoundError: - pass - _set_job(job_id, state="completed", status="success", percent=100, path=str(destination), model_name=model_name, imported=imported, message="Downloaded and imported into local Ollama" if imported else "Downloaded GGUF; Ollama import was not available on this filesystem") - except Exception as exc: - if temporary is not None: - try: - temporary.unlink() - except FileNotFoundError: - pass - _set_job(job_id, state="failed", status="error", error=str(exc)) - - -def _search_ollama_catalog(query: str, limit: int = 50) -> list[dict[str, Any]]: - needle = str(query or "").strip().lower() - if len(needle) < 2: - return [] - catalog = _ensure_catalog() - rows = list(catalog.get("models", [])) - family_map: dict[str, Any] = {} - raw_families = catalog.get("families") - if isinstance(raw_families, dict): - family_map = raw_families - rows.extend(variant for variants in family_map.values() if isinstance(variants, list) for variant in variants if isinstance(variant, dict)) - matches: list[dict[str, Any]] = [] - seen: set[str] = set() - for raw in rows: - name = str(raw.get("name") or raw.get("model") or "") - if not name or name in seen: - continue - view = _model_view(raw, source="catalog") - haystack = " ".join([name, view.get("family", ""), view.get("description", ""), *view.get("capabilities", []), *view.get("strengths", [])]).lower() - if needle in haystack: - seen.add(name) - matches.append(view) - return matches[:max(1, min(int(limit), 100))] - - class _VariantPageParser(HTMLParser): """Extract the public Ollama tag rows without depending on third-party HTML packages.""" @@ -1895,11 +1639,6 @@ class ModelRequest(BaseModel): placement: str = "gpu_ram" -class HuggingFaceDownloadRequest(BaseModel): - repo_id: str - filename: str - - class ConnectionRequest(BaseModel): url: str role: str = "local" @@ -2940,43 +2679,6 @@ def catalog_refresh() -> dict[str, Any]: return {"ok": bool(catalog.get("models")), "updated_at": catalog.get("fetched_at"), "count": len(catalog.get("models", [])), "error": catalog.get("last_error")} -@router.get("/catalog/search") -def catalog_search(q: str = "", limit: int = 30, include_huggingface: bool = False) -> dict[str, Any]: - query = str(q or "").strip()[:120] - if len(query) < 2: - return {"query": query, "results": [], "sources": ["ollama", "huggingface"] if include_huggingface else ["ollama"]} - ollama = _search_ollama_catalog(query, limit=limit) - huggingface = _search_huggingface(query, limit=limit) if include_huggingface else [] - combined = ollama + huggingface - return { - "query": query, - "results": combined, - "ollama_results": ollama, - "huggingface_results": huggingface, - "sources": ["ollama", "huggingface"] if include_huggingface else ["ollama"], - } - - -@router.get("/huggingface/files") -def huggingface_files(repo_id: str) -> dict[str, Any]: - repo_id = _valid_huggingface_repo(repo_id) - files = _huggingface_repo_files(repo_id) - return {"repo_id": repo_id, "files": [{key: value for key, value in item.items() if key != "download_url"} for item in files], "import_supported": _hf_ollama_import_available()} - - -@router.post("/huggingface/download") -def huggingface_download(body: HuggingFaceDownloadRequest) -> dict[str, Any]: - repo_id = _valid_huggingface_repo(body.repo_id) - filename = _valid_huggingface_filename(body.filename) - if not any(item["filename"] == filename for item in _huggingface_repo_files(repo_id)): - raise HTTPException(400, "Requested GGUF file was not found in the public Hugging Face repository") - job_id = uuid.uuid4().hex - with _jobs_lock: - _jobs[job_id] = {"id": job_id, "name": f"{repo_id}/{filename}", "action": "huggingface-download", "target": "local", "state": "running", "status": "starting", "percent": 0, "created_at": time.time(), "updated_at": time.time()} - threading.Thread(target=_run_huggingface_download, args=(job_id, repo_id, filename), daemon=True, name=f"huggingface-download-{job_id[:8]}").start() - return {"ok": True, "job_id": job_id, "repo_id": repo_id, "filename": filename, "message": "Hugging Face GGUF download started"} - - @router.post("/pull") def pull_model(body: ModelRequest) -> dict[str, Any]: name = _valid_name(body.name) diff --git a/plugin.yaml b/plugin.yaml index fd28c0c..2ed66cf 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: ollama-manager -version: 1.7.4 +version: 1.7.5 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: diff --git a/tests/test_validation_harness.py b/tests/test_validation_harness.py index e7c021a..5c3580f 100644 --- a/tests/test_validation_harness.py +++ b/tests/test_validation_harness.py @@ -89,80 +89,6 @@ class ValidationHarnessTests(unittest.TestCase): self.assertEqual(response, {"jobs": active}) listed.assert_called_once_with(active_only=True, conversation_id="conversation", limit=20) - def test_huggingface_search_normalizes_public_model_metadata(self): - with patch.object( - api, - "_json_list_request", - return_value=[ - { - "id": "Qwen/Qwen3.8-Flash-Next", - "downloads": 4810, - "likes": 3966, - "pipeline_tag": "image-text-to-text", - "library_name": "transformers", - "lastModified": "2026-08-27T00:00:00.000Z", - "sha": "abc123", - "tags": ["qwen", "conversational"], - } - ], - ): - results = api._search_huggingface("Qwen3.8-Flash-Next", limit=10) - self.assertEqual(len(results), 1) - model = results[0] - self.assertEqual(model["name"], "Qwen/Qwen3.8-Flash-Next") - self.assertEqual(model["source"], "huggingface") - self.assertEqual(model["hf_url"], "https://huggingface.co/Qwen/Qwen3.8-Flash-Next") - self.assertIn("vision", model["capabilities"]) - self.assertFalse(model["downloadable"]) - - def test_catalog_search_combines_ollama_and_huggingface_sources(self): - ollama = [{"name": "qwen3.8:latest", "source": "catalog"}] - huggingface = [{"name": "Qwen/Qwen3.8-Flash-Next", "source": "huggingface"}] - with patch.object(api, "_search_ollama_catalog", return_value=ollama) as ollama_search, patch.object( - api, "_search_huggingface", return_value=huggingface - ) as hf_search: - response = api.catalog_search("Qwen3.8", limit=10, include_huggingface=True) - self.assertEqual(response["results"], ollama + huggingface) - self.assertEqual(response["sources"], ["ollama", "huggingface"]) - ollama_search.assert_called_once_with("Qwen3.8", limit=10) - hf_search.assert_called_once_with("Qwen3.8", limit=10) - - def test_huggingface_groups_split_gguf_files_into_complete_sets(self): - metadata = { - "siblings": [ - {"rfilename": "Q4/Qwen-00001-of-00002.gguf"}, - {"rfilename": "Q4/Qwen-00002-of-00002.gguf"}, - {"rfilename": "Q8/Qwen.gguf"}, - {"rfilename": "Q8/README.md"}, - ] - } - with patch.object(api, "_json_request", return_value=metadata): - files = api._huggingface_repo_files("owner/repository") - self.assertEqual(len(files), 2) - split = next(item for item in files if item["split"]) - self.assertEqual(split["file_count"], 2) - self.assertEqual(len(split["filenames"]), 2) - single = next(item for item in files if not item["split"]) - self.assertEqual(single["filename"], "Q8/Qwen.gguf") - - def test_huggingface_download_rejects_non_gguf_paths(self): - with self.assertRaises(api.HTTPException): - api._valid_huggingface_filename("../model.safetensors") - with self.assertRaises(api.HTTPException): - api._valid_huggingface_filename("model.bin") - self.assertEqual(api._valid_huggingface_filename("Q4_K_M/model.gguf"), "Q4_K_M/model.gguf") - - def test_huggingface_download_queues_validated_file(self): - body = api.HuggingFaceDownloadRequest(repo_id="unsloth/Qwen3.8-Flash-Next-GGUF", filename="Q4_K_M/model.gguf") - fake_thread = type("Thread", (), {"start": lambda self: None}) - with patch.object(api, "_huggingface_repo_files", return_value=[{"filename": body.filename, "size": 123}]), patch.object( - api.threading, "Thread", return_value=fake_thread() - ) as thread: - response = api.huggingface_download(body) - self.assertTrue(response["ok"]) - self.assertEqual(response["filename"], body.filename) - thread.assert_called_once() - def test_primary_draft_validators_and_primary_compilation_produce_one_answer(self): body = api.ChatRequest( primary_model="primary",