From 8d3b43cac70ad1ce1b454a0fc32dfa69c3e034d0 Mon Sep 17 00:00:00 2001 From: Ashok Gelal Date: Sun, 18 Oct 2026 18:05:30 -0400 Subject: [PATCH 1/2] feat: add cluster collection and quality tiers to featured models --- README.md | 21 +++- v1/featured-models.json | 270 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 288 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f244031..2414716 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,8 @@ model can appear in two collections without being described twice. | Field | Meaning | | --- | --- | | `id`, `name` | Required. Everything else is optional. | -| `publisher`, `summary`, `parameters`, `context`, `capabilities`, `tags` | Description, for display and filtering. | +| `publisher`, `summary`, `parameters`, `context`, `capabilities`, `tags` | Description, for display and filtering. `"coding"` in `tags` marks a model that is good at writing code. | +| `tier` | Quality tier: `frontier`, `excellent`, `strong`, or `good`. | | `installs[].runtime` | `ollama`, `llamacpp`, or `mlx`. | | `installs[].model` | The Ollama tag. Ollama installs are addressed by it; Hugging Face installs are not. | | `installs[].repository`, `file`, `revision` | The Hugging Face locator. `revision` defaults to `main`. | @@ -292,6 +293,24 @@ model is multimodal, so those entries declare `vision`. download is a flow this format cannot describe. A repository publishing only shards gets no `llamacpp` entry. +**`tier` is a curated quality rating.** Msty Nexus Cluster Advisor ranks by it. It rates +general chat and reasoning quality against the open models available at the time: + +| Tier | Meaning | +| --- | --- | +| `frontier` | The best open models, close to hosted frontier models. | +| `excellent` | One step below: the previous generation's flagships and the top coding models. | +| `strong` | Capable mid-size models, roughly 20B to 130B. | +| `good` | Small models, and models with one narrow job (OCR, embeddings). | + +Tiers are relative, so review them when a new generation of models is released. A client +that finds no `tier` estimates one from the model's size and says so. The field is +additive: older Nexus builds ignore it, so `schema_version` stays 1. + +**The `cluster` collection is a contract.** Cluster Advisor reads it by id. It holds MLX +models too large for one Mac, from model families the cluster runtime can split across +Macs. + ## Refresh Regenerated by hand rather than on a schedule, because the curation is the point. The diff --git a/v1/featured-models.json b/v1/featured-models.json index c49ae28..67ccca9 100644 --- a/v1/featured-models.json +++ b/v1/featured-models.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "generated_at": "2026-08-15T22:58:28Z", + "generated_at": "2026-10-18T22:04:21Z", "collections": [ { "id": "start-here", @@ -87,6 +87,23 @@ "gpt-oss-120b", "qwen3.5-122b" ] + }, + { + "id": "cluster", + "title": "For a Mac cluster", + "description": "Too large for one Mac. Split across a Thunderbolt-connected cluster.", + "models": [ + "muse-spark-open", + "glm-5.3", + "deepseek-v3.2", + "qwen3-coder-480b", + "qwen3.5-397b", + "glm-4.7", + "minimax-m2.7", + "qwen3.5-122b", + "gpt-oss-120b", + "glm-4.5-air" + ] } ], "models": [ @@ -103,6 +120,7 @@ "reasoning", "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -137,6 +155,7 @@ "tools", "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -170,6 +189,7 @@ "chat", "tools" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -202,6 +222,7 @@ "chat", "tools" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -236,6 +257,10 @@ "reasoning", "vision" ], + "tags": [ + "coding" + ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -271,6 +296,10 @@ "reasoning", "vision" ], + "tags": [ + "coding" + ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -305,6 +334,10 @@ "tools", "reasoning" ], + "tags": [ + "coding" + ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -338,6 +371,7 @@ "tools", "reasoning" ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -371,6 +405,7 @@ "tools", "reasoning" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -404,6 +439,7 @@ "tools", "vision" ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -438,6 +474,10 @@ "code", "tools" ], + "tags": [ + "coding" + ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -471,6 +511,10 @@ "code", "tools" ], + "tags": [ + "coding" + ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -504,6 +548,10 @@ "code", "tools" ], + "tags": [ + "coding" + ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -537,6 +585,10 @@ "code", "tools" ], + "tags": [ + "coding" + ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -570,6 +622,10 @@ "code", "tools" ], + "tags": [ + "coding" + ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -602,6 +658,7 @@ "chat", "reasoning" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -627,13 +684,14 @@ "id": "olmo-3.1-think", "name": "Olmo 3.1 32B Think", "publisher": "Ai2", - "summary": "A fully open model \u2014 weights, data, and training \u2014 tuned to reason.", + "summary": "A fully open model — weights, data, and training — tuned to reason.", "parameters": "32B", "context": 65536, "capabilities": [ "chat", "reasoning" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -667,6 +725,7 @@ "reasoning", "tools" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -699,6 +758,7 @@ "chat", "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -732,6 +792,7 @@ "chat", "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -764,6 +825,7 @@ "capabilities": [ "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -796,6 +858,7 @@ "capabilities": [ "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -829,6 +892,7 @@ "chat", "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -863,6 +927,7 @@ "tools", "vision" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -896,6 +961,7 @@ "chat", "tools" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -927,6 +993,7 @@ "capabilities": [ "embeddings" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -958,6 +1025,7 @@ "capabilities": [ "embeddings" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -989,6 +1057,7 @@ "capabilities": [ "embeddings" ], + "tier": "good", "installs": [ { "runtime": "ollama", @@ -1022,6 +1091,7 @@ "tools", "reasoning" ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -1056,6 +1126,7 @@ "reasoning", "vision" ], + "tier": "strong", "installs": [ { "runtime": "ollama", @@ -1077,6 +1148,201 @@ "size_bytes": 69620619709 } ] + }, + { + "id": "muse-spark-open", + "name": "Muse Spark Open 816B", + "publisher": "Meta", + "summary": "Meta's largest open model, with a one-million-token context.", + "parameters": "816B", + "context": 1048576, + "capabilities": [ + "chat", + "tools", + "reasoning" + ], + "tier": "frontier", + "installs": [ + { + "runtime": "mlx", + "repository": "mlx-community/Muse-Spark-Open-816B-A42B-4bit", + "size_bytes": 532809372094 + } + ] + }, + { + "id": "glm-5.3", + "name": "GLM-5.3", + "publisher": "Z.ai", + "summary": "Z.ai's flagship, strong at agentic coding, with a one-million-token context.", + "parameters": "753B", + "context": 1048576, + "capabilities": [ + "chat", + "tools", + "reasoning" + ], + "tags": [ + "coding" + ], + "tier": "frontier", + "installs": [ + { + "runtime": "mlx", + "repository": "mlx-community/GLM-5.3-4bit", + "size_bytes": 418341881358 + } + ] + }, + { + "id": "deepseek-v3.2", + "name": "DeepSeek V3.2", + "publisher": "DeepSeek", + "summary": "DeepSeek's flagship, with sparse attention for long inputs.", + "parameters": "685B", + "context": 163840, + "capabilities": [ + "chat", + "tools", + "reasoning" + ], + "tags": [ + "coding" + ], + "tier": "excellent", + "installs": [ + { + "runtime": "mlx", + "repository": "mlx-community/DeepSeek-V3.2-4bit", + "size_bytes": 378096490376 + } + ] + }, + { + "id": "qwen3-coder-480b", + "name": "Qwen3 Coder 480B", + "publisher": "Qwen", + "summary": "Qwen's largest coding model, for agentic work across a repository.", + "parameters": "480B", + "context": 262144, + "capabilities": [ + "chat", + "code", + "tools" + ], + "tags": [ + "coding" + ], + "tier": "excellent", + "installs": [ + { + "runtime": "ollama", + "model": "qwen3-coder:480b", + "size_bytes": 290000000000, + "quantization": "Q4_K_M" + }, + { + "runtime": "mlx", + "repository": "mlx-community/Qwen3-Coder-480B-A35B-Instruct-4bit", + "size_bytes": 270104520199 + } + ] + }, + { + "id": "qwen3.5-397b", + "name": "Qwen3.5 397B", + "publisher": "Qwen", + "summary": "The largest Qwen3.5, for chat, research, and code.", + "parameters": "397B", + "context": 262144, + "capabilities": [ + "chat", + "tools", + "reasoning" + ], + "tags": [ + "coding" + ], + "tier": "excellent", + "installs": [ + { + "runtime": "mlx", + "repository": "mlx-community/Qwen3.5-397B-A17B-4bit", + "size_bytes": 223888201461 + } + ] + }, + { + "id": "glm-4.7", + "name": "GLM-4.7", + "publisher": "Z.ai", + "summary": "Z.ai's previous flagship, strong at coding and tool use, at half the size of GLM-5.3.", + "parameters": "358B", + "context": 202752, + "capabilities": [ + "chat", + "tools", + "reasoning" + ], + "tags": [ + "coding" + ], + "tier": "excellent", + "installs": [ + { + "runtime": "mlx", + "repository": "mlx-community/GLM-4.7-4bit", + "size_bytes": 198577656712 + } + ] + }, + { + "id": "minimax-m2.7", + "name": "MiniMax M2.7", + "publisher": "MiniMax", + "summary": "A compact mixture-of-experts model built for coding and agent work.", + "parameters": "229B", + "context": 196608, + "capabilities": [ + "chat", + "tools", + "reasoning" + ], + "tags": [ + "coding" + ], + "tier": "excellent", + "installs": [ + { + "runtime": "mlx", + "repository": "mlx-community/MiniMax-M2.7-4bit", + "size_bytes": 128682648831 + } + ] + }, + { + "id": "glm-4.5-air", + "name": "GLM-4.5 Air", + "publisher": "Z.ai", + "summary": "A lighter GLM that two smaller Macs can share.", + "parameters": "110B", + "context": 131072, + "capabilities": [ + "chat", + "tools", + "reasoning" + ], + "tags": [ + "coding" + ], + "tier": "strong", + "installs": [ + { + "runtime": "mlx", + "repository": "mlx-community/GLM-4.5-Air-4bit", + "size_bytes": 60160167177 + } + ] } ] } From 3ebb051a5a8c99e67c86bc4568b23548483d2da2 Mon Sep 17 00:00:00 2001 From: Ashok Gelal <401055+ashokgelal@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:36:22 -0400 Subject: [PATCH 2/2] feat: build and refresh the featured models list daily * feat: build and refresh the featured models list daily * chore: refresh the featured models --- .github/workflows/ci.yml | 1 + .github/workflows/refresh-featured.yml | 63 ++++ README.md | 37 ++- package.json | 5 +- scripts/featured-curation.mjs | 364 ++++++++++++++++++++++ scripts/featured-generate.mjs | 143 +++++++++ scripts/featured-report.mjs | 97 ++++++ scripts/featured-verify.mjs | 30 ++ scripts/featured.mjs | 414 +++++++++++++++++++++++++ scripts/featured.test.mjs | 161 ++++++++++ v1/featured-models.json | 98 +++++- 11 files changed, 1393 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/refresh-featured.yml create mode 100644 scripts/featured-curation.mjs create mode 100644 scripts/featured-generate.mjs create mode 100644 scripts/featured-report.mjs create mode 100644 scripts/featured-verify.mjs create mode 100644 scripts/featured.mjs create mode 100644 scripts/featured.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c61d0a..5c38786 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,6 @@ jobs: run: | node scripts/verify.mjs node scripts/ollama-verify.mjs + node scripts/featured-verify.mjs node scripts/runtime-feed-verify-all.mjs python3 -m unittest discover -s scripts -p '*_test.py' diff --git a/.github/workflows/refresh-featured.yml b/.github/workflows/refresh-featured.yml new file mode 100644 index 0000000..8404004 --- /dev/null +++ b/.github/workflows/refresh-featured.yml @@ -0,0 +1,63 @@ +name: refresh-featured + +# Refreshes the curated featured models list: files, sizes, parameters, and +# context from v1/ollama-models.json and the Hugging Face API. Which models +# appear is decided only by scripts/featured-curation.mjs, which this job never +# edits. It also reports possible newer versions for a maintainer to review. +on: + schedule: + # Daily, after the Ollama refresh has had a chance to land. GitHub runs + # scheduled workflows on a best-effort basis; the minute does not matter. + - cron: "53 5 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: refresh-featured + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # See refresh.yml: pushes made with the default token do not reset + # GitHub's 60-day scheduled-workflow cutoff. + token: ${{ secrets.REFRESH_TOKEN || github.token }} + fetch-depth: 0 + + - name: Show tool versions + run: node --version + + - name: Test the builder + run: node --test scripts/featured.test.mjs + + # Fails without writing when any reference cannot be confirmed, so the + # published file stands until a maintainer fixes the curation. + - name: Generate + run: node scripts/featured-generate.mjs + + - name: Verify the artifact + run: node scripts/featured-verify.mjs + + - name: Commit + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "nothing to publish" + exit 0 + fi + git config user.name "model-catalog[bot]" + git config user.email "noreply@cloudstack.llc" + git add v1/featured-models.json + git commit -m "chore: refresh the featured models" + git push + + # Leads only: a report failure never fails the refresh. + - name: Report possible newer versions + continue-on-error: true + run: node scripts/featured-report.mjs >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 2414716..1b52620 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ https://raw.githubusercontent.com/cloudstack-llc/model-catalog/main/v1/smart-rou | --- | --- | --- | --- | | `v1/prices.json` | Token pricing for hosted models | [models.dev](https://models.dev) | 6 hours | | `v1/ollama-models.json` | The Ollama library: models, tags, sizes, context windows | [ollama.com](https://ollama.com/library) | 12 hours | -| `v1/featured-models.json` | Curated local models worth downloading, grouped into collections | Curated; resolved against Ollama and Hugging Face | On change | +| `v1/featured-models.json` | Curated local models worth downloading, grouped into collections | Curated; resolved against Ollama and Hugging Face | Daily | | `v1/smart-route-starters.json` | Model-neutral Smart Route starters, grouped by use | Curated | On change | | `v2/runtime/{stable,dev}/runtime-release-feed.signed.json` | Signed Msty Nexus Runtime releases | Ollama, llama.cpp, and Msty MLX metadata | 4 hours | @@ -228,8 +228,8 @@ file stands. `v1/featured-models.json` — the local models worth recommending to somebody who has just installed a runtime and does not yet know what to download. -Unlike the other two files this one is curated. Which models appear is a judgement; every -value attached to them is not. Tags, sizes, context windows and parameter counts come from +Unlike the scraped files this one is curated. Which models appear is a judgement, and it +lives in `scripts/featured-curation.mjs`; every value attached to them is not. Tags, sizes, context windows and parameter counts come from `ollama-models.json`, and repositories, GGUF file names and byte counts come from the Hugging Face API. A reference that cannot be confirmed fails the build rather than shipping — a featured row with a dead download behind it is worse than one fewer row. @@ -311,9 +311,32 @@ additive: older Nexus builds ignore it, so `schema_version` stays 1. models too large for one Mac, from model families the cluster runtime can split across Macs. +**An Ollama size published as a range uses the upper end.** Ollama shows a range, such as +`6.6GB - 7.6GB`, for a tag that resolves to different files per platform. `size_bytes` is +what the download may cost, so a fit check that overestimates is the safer error. + ## Refresh -Regenerated by hand rather than on a schedule, because the curation is the point. The -builder lives in the Msty Nexus repository at `scripts/build-featured-models.py`; it reads -the committed `ollama-models.json` from this repository and the Hugging Face API, and -fails on any reference it cannot confirm. +A daily job (`refresh-featured.yml`) rebuilds the file from the curation, the committed +`ollama-models.json`, and the Hugging Face API. It refreshes files, sizes, parameters, +context and popularity, and never adds or drops a model: only a reviewed edit of +`scripts/featured-curation.mjs` does that. + +The job commits only when something other than `generated_at` or popularity changed; +Msty Nexus reads popularity live and treats the published numbers as a fallback. Any +reference it cannot confirm, or any Ollama size it cannot read, fails the run without +writing, so the published file stands until a maintainer fixes the curation. + +After each run, `featured-report.mjs` lists mlx-community repositories that may be newer +versions of featured models (by each model's `watch` term) in the job summary. These are +leads for a maintainer, not changes. + +To change the list, edit `scripts/featured-curation.mjs`, then rebuild and commit both: + +```bash +node scripts/featured-generate.mjs # fetch and rewrite v1/featured-models.json +node scripts/featured-verify.mjs # offline: shape, sizes, and match with the curation +node --test scripts/featured.test.mjs +``` + +CI runs the verifier, so a curation edit without a rebuild fails the pull request. diff --git a/package.json b/package.json index 88baef1..c3572ff 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "license": "MIT", "scripts": { "test": "node --test scripts/*.test.mjs", - "verify": "node scripts/verify.mjs && node scripts/ollama-verify.mjs && node scripts/smart-route-starters-verify.mjs && node scripts/runtime-feed-verify-all.mjs", + "verify": "node scripts/verify.mjs && node scripts/ollama-verify.mjs && node scripts/featured-verify.mjs && node scripts/smart-route-starters-verify.mjs && node scripts/runtime-feed-verify-all.mjs", "generate": "node scripts/generate.mjs", - "generate:ollama": "node scripts/ollama-generate.mjs" + "generate:ollama": "node scripts/ollama-generate.mjs", + "generate:featured": "node scripts/featured-generate.mjs" } } diff --git a/scripts/featured-curation.mjs b/scripts/featured-curation.mjs new file mode 100644 index 0000000..c62d785 --- /dev/null +++ b/scripts/featured-curation.mjs @@ -0,0 +1,364 @@ +// The curated input for v1/featured-models.json. Which models appear, how they +// are described, and how they rank is a judgement, and it lives here. Every +// other value (sizes, files, parameters, context) is read by the builder from +// v1/ollama-models.json and the Hugging Face API, and a reference it cannot +// confirm fails the build. +// +// The scheduled refresh never edits this file. Adding, dropping, or replacing a +// model is a reviewed change to this file. +// +// Per model: +// ollama the Ollama tag, read from v1/ollama-models.json +// gguf a Hugging Face repository with a single-file GGUF +// mlx a Hugging Face MLX repository; the whole repository is the download +// base the publisher's original repository, for rows with no Ollama tag: +// parameters come from it, context from the MLX config.json +// tier frontier | excellent | strong | good (see README) +// coding true when the model is good at writing code ("coding" tag) +// watch the start of a repository name for the newer-versions report, +// e.g. "GLM-" or "MiniMax-M" +// +// No image-generation entries: no runtime Nexus ships (Ollama, llama.cpp, MLX) +// can generate images, so a featured image model would offer a download that +// runs nowhere. + +export const MODELS = [ + // --- start-here --- + { + id: "qwen3.5-9b", name: "Qwen3.5 9B", publisher: "Qwen", + summary: "Strong all-round chat with a very long context. A good first model.", + capabilities: ["chat", "tools", "reasoning", "vision"], + ollama: "qwen3.5:9b", gguf: "unsloth/Qwen3.5-9B-GGUF", mlx: "mlx-community/Qwen3.5-9B-MLX-4bit", + tier: "good", watch: "Qwen3.", + }, + { + id: "gemma4-12b", name: "Gemma 4 12B", publisher: "Google", + summary: "Frontier-level quality at a size a laptop can hold.", + capabilities: ["chat", "tools", "vision"], + ollama: "gemma4:12b", gguf: "unsloth/gemma-4-12b-it-GGUF", mlx: "mlx-community/gemma-4-12B-it-qat-4bit", + tier: "good", watch: "gemma-4", + }, + { + id: "lfm2.5-8b", name: "LFM2.5 8B", publisher: "Liquid AI", + summary: "Built for fast, reliable tool calling on consumer hardware.", + capabilities: ["chat", "tools"], + ollama: "lfm2.5:8b", gguf: "LiquidAI/LFM2.5-8B-A1B-GGUF", mlx: "mlx-community/LFM2.5-8B-A1B-MLX-4bit", + tier: "good", watch: "LFM", + }, + { + id: "ministral-3-8b", name: "Ministral 3 8B", publisher: "Mistral AI", + summary: "Mistral's edge family, sized to run on a single machine.", + capabilities: ["chat", "tools"], + ollama: "ministral-3:8b", gguf: "mistralai/Ministral-3-8B-Instruct-2512-GGUF", + mlx: "mlx-community/Ministral-3-8B-Instruct-2512-4bit", + tier: "good", watch: "Ministral", + }, + // --- everyday --- + { + id: "qwen3.8-27b", name: "Qwen3.8 27B", publisher: "Qwen", + summary: "Qwen's flagship open model, strong on coding and research.", + capabilities: ["chat", "tools", "reasoning"], + ollama: "qwen3.8:27b", gguf: "ggml-org/Qwen3.8-27B-GGUF", mlx: "mlx-community/Qwen3.8-27B-4bit", + tier: "strong", coding: true, watch: "Qwen3.", + }, + { + id: "qwen3.6-35b", name: "Qwen3.6 35B", publisher: "Qwen", + summary: "A mixture-of-experts model tuned for agentic coding and long thinking.", + capabilities: ["chat", "tools", "reasoning"], + ollama: "qwen3.6:35b", gguf: "unsloth/Qwen3.6-35B-A3B-GGUF", mlx: "mlx-community/Qwen3.6-35B-A3B-4bit", + tier: "strong", coding: true, watch: "Qwen3.", + }, + { + id: "glm-4.7-flash", name: "GLM-4.7 Flash", publisher: "Z.ai", + summary: "The strongest of the 30B class, with a 198K context window.", + capabilities: ["chat", "tools", "reasoning"], + ollama: "glm-4.7-flash:latest", gguf: "ggml-org/GLM-4.7-Flash-GGUF", mlx: "mlx-community/GLM-4.7-Flash-4bit", + tier: "strong", coding: true, watch: "GLM-", + }, + { + id: "nemotron-3.5-lightning", name: "Nemotron 3.5 Lightning 30B", publisher: "NVIDIA", + summary: "A 30B mixture-of-experts model with a one-million-token context.", + capabilities: ["chat", "tools", "reasoning"], + ollama: "nemotron-3.5-lightning:30b", gguf: "ggml-org/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF", + mlx: "mlx-community/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-4bit", + tier: "strong", watch: "NVIDIA-Nemotron", + }, + { + id: "gpt-oss-20b", name: "gpt-oss 20B", publisher: "OpenAI", + summary: "OpenAI's open-weight model for reasoning and agentic work.", + capabilities: ["chat", "tools", "reasoning"], + ollama: "gpt-oss:20b", gguf: "ggml-org/gpt-oss-20b-GGUF", mlx: "mlx-community/gpt-oss-20b-MXFP4-Q8", + tier: "good", watch: "gpt-oss", + }, + { + id: "muse-glimmer-30b", name: "Muse Glimmer 30B", publisher: "Meta", + summary: "Meta's open model for always-on local agents.", + capabilities: ["chat", "tools"], + ollama: "muse-glimmer:30b", gguf: "meta-models/Muse-Glimmer-30B-GGUF", mlx: "mlx-community/Muse-Glimmer-30B-4bit", + tier: "strong", watch: "Muse", + }, + // --- coding --- + { + id: "ornith-9b", name: "Ornith 1.0 9B", publisher: "Ornith", + summary: "A self-improving model for agentic coding, small enough for a laptop.", + capabilities: ["chat", "code", "tools"], + ollama: "ornith:9b", gguf: "ornith-ai/Ornith-1.0-9B-GGUF", mlx: "mlx-community/Ornith-1.0-9B-4bit", + tier: "good", coding: true, watch: "Ornith", + }, + { + id: "ornith-35b", name: "Ornith 1.0 35B", publisher: "Ornith", + summary: "The larger Ornith, for coding work that needs the headroom.", + capabilities: ["chat", "code", "tools"], + ollama: "ornith:35b", gguf: "ornith-ai/Ornith-1.0-35B-GGUF", mlx: "mlx-community/Ornith-1.0-35B-4bit", + tier: "strong", coding: true, watch: "Ornith", + }, + { + id: "north-mini-code", name: "North Mini Code 1.0", publisher: "Cohere", + summary: "Cohere's developer model: 30B mixture-of-experts, 488K context.", + capabilities: ["chat", "code", "tools"], + ollama: "north-mini-code-1.0:latest", gguf: "unsloth/North-Mini-Code-1.0-GGUF", + mlx: "mlx-community/North-Mini-Code-1.0-4bit", + tier: "strong", coding: true, watch: "North", + }, + { + id: "laguna-xs-2.1", name: "Laguna XS 2.1", publisher: "poolside", + summary: "A 33B mixture-of-experts model with 3B active, built for long-horizon work.", + capabilities: ["chat", "code", "tools"], + ollama: "laguna-xs-2.1:latest", gguf: "poolside/Laguna-XS-2.1-GGUF", mlx: "mlx-community/Laguna-XS-2.1-4bit", + tier: "strong", coding: true, watch: "Laguna", + }, + { + id: "qwen3-coder-next", name: "Qwen3 Coder Next", publisher: "Qwen", + summary: "Qwen's coding-focused model for agentic editing across a codebase.", + capabilities: ["chat", "code", "tools"], + ollama: "qwen3-coder-next:latest", gguf: "unsloth/Qwen3-Coder-Next-GGUF", mlx: "mlx-community/Qwen3-Coder-Next-4bit", + tier: "strong", coding: true, watch: "Qwen3-Coder", + }, + // --- reasoning --- + { + id: "lfm2.5-thinking", name: "LFM2.5 Thinking 1.2B", publisher: "Liquid AI", + summary: "Shows its working, and still fits in under a gigabyte.", + capabilities: ["chat", "reasoning"], + ollama: "lfm2.5-thinking:1.2b", gguf: "LiquidAI/LFM2.5-1.2B-Thinking-GGUF", + mlx: "mlx-community/LFM2.5-1.2B-Thinking-4bit", + tier: "good", watch: "LFM", + }, + { + id: "olmo-3.1-think", name: "Olmo 3.1 32B Think", publisher: "Ai2", + summary: "A fully open model — weights, data, and training — tuned to reason.", + capabilities: ["chat", "reasoning"], + ollama: "olmo-3.1:32b-think", gguf: "bartowski/allenai_Olmo-3.1-32B-Think-GGUF", + mlx: "mlx-community/Olmo-3.1-32B-Think-4bit", + tier: "good", watch: "Olmo", + }, + { + id: "nemotron-3-nano", name: "Nemotron 3 Nano 30B", publisher: "NVIDIA", + summary: "Efficient agentic reasoning with a one-million-token context.", + capabilities: ["chat", "reasoning", "tools"], + ollama: "nemotron-3-nano:30b", gguf: "unsloth/Nemotron-3-Nano-30B-A3B-GGUF", + mlx: "mlx-community/NVIDIA-Nemotron-3-Nano-30B-A3B-4bit", + tier: "good", watch: "NVIDIA-Nemotron", + }, + // --- vision --- + { + id: "qwen3-vl-8b", name: "Qwen3-VL 8B", publisher: "Qwen", + summary: "Reads screenshots, documents, and charts, and answers questions about them.", + capabilities: ["chat", "vision"], + ollama: "qwen3-vl:8b", gguf: "unsloth/Qwen3-VL-8B-Instruct-GGUF", mlx: "mlx-community/Qwen3-VL-8B-Instruct-4bit", + tier: "good", watch: "Qwen3-VL", + }, + { + id: "minicpm-v4.6", name: "MiniCPM-V 4.6", publisher: "OpenBMB", + summary: "A pocket-sized vision model for image and video understanding.", + capabilities: ["chat", "vision"], + ollama: "minicpm-v4.6:1b", gguf: "openbmb/MiniCPM-V-4.6-gguf", mlx: "mlx-community/MiniCPM-V-4.6-4bit", + tier: "good", watch: "MiniCPM", + }, + { + id: "glm-ocr", name: "GLM-OCR", publisher: "Z.ai", + summary: "Turns complex documents into text. Small enough to keep loaded.", + capabilities: ["vision"], + ollama: "glm-ocr:latest", gguf: "ggml-org/GLM-OCR-GGUF", mlx: "mlx-community/GLM-OCR-4bit", + tier: "good", watch: "GLM-OCR", + }, + { + id: "deepseek-ocr", name: "DeepSeek-OCR", publisher: "DeepSeek", + summary: "Token-efficient OCR, for putting scanned pages into a pipeline.", + capabilities: ["vision"], + ollama: "deepseek-ocr:3b", gguf: "ggml-org/DeepSeek-OCR-GGUF", mlx: "mlx-community/DeepSeek-OCR-8bit", + tier: "good", watch: "DeepSeek-OCR", + }, + // --- small --- + { + id: "qwen3.5-0.8b", name: "Qwen3.5 0.8B", publisher: "Qwen", + summary: "Under a gigabyte, and still holds a conversation.", + capabilities: ["chat"], + ollama: "qwen3.5:0.8b", gguf: "unsloth/Qwen3.5-0.8B-GGUF", mlx: "mlx-community/Qwen3.5-0.8B-4bit", + tier: "good", + }, + { + id: "qwen3.5-2b", name: "Qwen3.5 2B", publisher: "Qwen", + summary: "The smallest Qwen3.5 that still does tool calling well.", + capabilities: ["chat", "tools"], + ollama: "qwen3.5:2b", gguf: "bartowski/Qwen_Qwen3.5-2B-GGUF", mlx: "mlx-community/Qwen3.5-2B-MLX-8bit", + tier: "good", + }, + { + id: "granite4.1-3b", name: "Granite 4.1 3B", publisher: "IBM", + summary: "An enterprise-ready small model with a 128K context.", + capabilities: ["chat", "tools"], + ollama: "granite4.1:3b", gguf: "ibm-granite/granite-4.1-3b-GGUF", mlx: "mlx-community/granite-4.1-3b-4bit", + tier: "good", watch: "granite", + }, + // --- embeddings --- + { + id: "qwen3-embedding-0.6b", name: "Qwen3 Embedding 0.6B", publisher: "Qwen", + summary: "Turns text into vectors for search. Small enough to run beside a chat model.", + capabilities: ["embeddings"], + ollama: "qwen3-embedding:0.6b", gguf: "Qwen/Qwen3-Embedding-0.6B-GGUF", mlx: "mlx-community/Qwen3-Embedding-0.6B-8bit", + tier: "good", watch: "Qwen3-Embedding", + }, + { + id: "embeddinggemma-300m", name: "EmbeddingGemma 300M", publisher: "Google", + summary: "A 300M embedding model, quantization-aware trained.", + capabilities: ["embeddings"], + ollama: "embeddinggemma:300m", gguf: "ggml-org/embeddinggemma-300M-GGUF", mlx: "mlx-community/embeddinggemma-300m-4bit", + tier: "good", + }, + { + id: "bge-m3", name: "BGE-M3", publisher: "BAAI", + summary: "Multilingual embeddings for search across many languages at once.", + capabilities: ["embeddings"], + ollama: "bge-m3:567m", gguf: "gpustack/bge-m3-GGUF", mlx: "mlx-community/bge-m3-mlx-4bit", + tier: "good", + }, + // --- big machines --- + { + id: "gpt-oss-120b", name: "gpt-oss 120B", publisher: "OpenAI", + summary: "The larger open-weight OpenAI model, for workstations with real memory.", + capabilities: ["chat", "tools", "reasoning"], + ollama: "gpt-oss:120b", gguf: "ggml-org/gpt-oss-120b-GGUF", mlx: "mlx-community/gpt-oss-120b-MXFP4-Q8", + tier: "strong", watch: "gpt-oss", + }, + { + id: "qwen3.5-122b", name: "Qwen3.5 122B", publisher: "Qwen", + summary: "The full Qwen3.5. Needs a workstation, and rewards one.", + capabilities: ["chat", "tools", "reasoning", "vision"], + ollama: "qwen3.5:122b", gguf: "unsloth/Qwen3.5-122B-A10B-GGUF", mlx: "mlx-community/Qwen3.5-122B-A10B-4bit", + tier: "strong", watch: "Qwen3.", + }, + // --- cluster --- + // Too large for one Mac, so most have no local Ollama tag and every GGUF of + // them is sharded; MLX is the install. Only families the Msty Nexus cluster + // worker can split across Macs belong here (msty-nexus + // internal/cluster/model_capabilities.go), and none may need + // trust_remote_code to load, because the worker never passes it. + { + id: "muse-spark-open", name: "Muse Spark Open 816B", publisher: "Meta", + summary: "Meta's largest open model, with a one-million-token context.", + capabilities: ["chat", "tools", "reasoning"], + mlx: "mlx-community/Muse-Spark-Open-816B-A42B-4bit", base: "meta-models/Muse-Spark-Open-816B-A42B", + tier: "frontier", watch: "Muse", + }, + { + id: "glm-5.3", name: "GLM-5.3", publisher: "Z.ai", + summary: "Z.ai's flagship, strong at agentic coding, with a one-million-token context.", + capabilities: ["chat", "tools", "reasoning"], + mlx: "mlx-community/GLM-5.3-4bit", base: "zai-org/GLM-5.3-BF16", + tier: "frontier", coding: true, watch: "GLM-", + }, + { + id: "deepseek-v3.2", name: "DeepSeek V3.2", publisher: "DeepSeek", + summary: "DeepSeek's flagship, with sparse attention for long inputs.", + capabilities: ["chat", "tools", "reasoning"], + mlx: "mlx-community/DeepSeek-V3.2-4bit", base: "deepseek-ai/DeepSeek-V3.2", + tier: "excellent", coding: true, watch: "DeepSeek-V", + }, + { + id: "qwen3-coder-480b", name: "Qwen3 Coder 480B", publisher: "Qwen", + summary: "Qwen's largest coding model, for agentic work across a repository.", + capabilities: ["chat", "code", "tools"], + ollama: "qwen3-coder:480b", mlx: "mlx-community/Qwen3-Coder-480B-A35B-Instruct-4bit", + tier: "excellent", coding: true, watch: "Qwen3-Coder", + }, + { + id: "qwen3.5-397b", name: "Qwen3.5 397B", publisher: "Qwen", + summary: "The largest Qwen3.5, for chat, research, and code.", + capabilities: ["chat", "tools", "reasoning"], + mlx: "mlx-community/Qwen3.5-397B-A17B-4bit", base: "Qwen/Qwen3.5-397B-A17B", + tier: "excellent", coding: true, watch: "Qwen3.", + }, + { + id: "glm-4.7", name: "GLM-4.7", publisher: "Z.ai", + summary: "Z.ai's previous flagship, strong at coding and tool use, at half the size of GLM-5.3.", + capabilities: ["chat", "tools", "reasoning"], + mlx: "mlx-community/GLM-4.7-4bit", base: "zai-org/GLM-4.7", + tier: "excellent", coding: true, watch: "GLM-", + }, + { + id: "minimax-m2.7", name: "MiniMax M2.7", publisher: "MiniMax", + summary: "A compact mixture-of-experts model built for coding and agent work.", + capabilities: ["chat", "tools", "reasoning"], + mlx: "mlx-community/MiniMax-M2.7-4bit", base: "MiniMaxAI/MiniMax-M2.7", + tier: "excellent", coding: true, watch: "MiniMax-M", + }, + { + id: "glm-4.5-air", name: "GLM-4.5 Air", publisher: "Z.ai", + summary: "A lighter GLM that two smaller Macs can share.", + capabilities: ["chat", "tools", "reasoning"], + mlx: "mlx-community/GLM-4.5-Air-4bit", base: "zai-org/GLM-4.5-Air", + tier: "strong", coding: true, watch: "GLM-", + }, +]; + +export const COLLECTIONS = [ + { + id: "start-here", title: "Start here", + description: "If you have never run a model locally, one of these.", + models: ["qwen3.5-9b", "gemma4-12b", "lfm2.5-8b", "ministral-3-8b"], + }, + { + id: "everyday", title: "Everyday chat", + description: "General assistants for writing, summarising, and answering questions.", + models: ["qwen3.8-27b", "qwen3.6-35b", "glm-4.7-flash", "nemotron-3.5-lightning", "gpt-oss-20b", + "muse-glimmer-30b"], + }, + { + id: "coding", title: "For writing code", + description: "Tuned for completion, refactoring, and working inside a codebase.", + models: ["ornith-9b", "ornith-35b", "north-mini-code", "laguna-xs-2.1", "qwen3-coder-next"], + }, + { + id: "reasoning", title: "Thinks before it answers", + description: "Slower, and better at maths, logic, and multi-step problems.", + models: ["lfm2.5-thinking", "olmo-3.1-think", "nemotron-3-nano"], + }, + { + id: "vision", title: "Reads images", + description: "Answers questions about screenshots, documents, and photographs.", + models: ["qwen3-vl-8b", "minicpm-v4.6", "glm-ocr", "deepseek-ocr"], + }, + { + id: "small", title: "Runs on almost anything", + description: "A few billion parameters at most, for older machines and small devices.", + models: ["qwen3.5-0.8b", "qwen3.5-2b", "granite4.1-3b"], + }, + { + id: "embeddings", title: "Search and retrieval", + description: "Turn documents into vectors. Pair one with a chat model for RAG.", + models: ["qwen3-embedding-0.6b", "embeddinggemma-300m", "bge-m3"], + }, + { + id: "big-machines", title: "If you have the memory", + description: "Large models for workstations. Check the fit before downloading.", + models: ["gpt-oss-120b", "qwen3.5-122b"], + }, + // Msty Nexus Cluster Advisor reads this collection by id, so the id is a + // contract. + { + id: "cluster", title: "For a Mac cluster", + description: "Too large for one Mac. Split across a Thunderbolt-connected cluster.", + models: ["muse-spark-open", "glm-5.3", "deepseek-v3.2", "qwen3-coder-480b", "qwen3.5-397b", "glm-4.7", + "minimax-m2.7", "qwen3.5-122b", "gpt-oss-120b", "glm-4.5-air"], + }, +]; diff --git a/scripts/featured-generate.mjs b/scripts/featured-generate.mjs new file mode 100644 index 0000000..f17d56e --- /dev/null +++ b/scripts/featured-generate.mjs @@ -0,0 +1,143 @@ +// Rebuilds v1/featured-models.json from scripts/featured-curation.mjs, the +// committed v1/ollama-models.json, and the Hugging Face API. +// +// Run: node scripts/featured-generate.mjs [--out v1/featured-models.json] [--force] +// +// The curation decides which models appear; this refreshes everything else +// (files, sizes, parameters, context, popularity). Any reference it cannot +// confirm fails the run without writing, so yesterday's file stands. --force +// writes even when only generated_at or popularity moved. + +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import { COLLECTIONS, MODELS } from "./featured-curation.mjs"; +import { buildArtifact, hubReferences, indexOllama, serialize, stableView } from "./featured.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HUB = "https://huggingface.co"; +const USER_AGENT = "msty-model-catalog/1.0 (+https://github.com/cloudstack-llc/catalogs)"; +const CONCURRENCY = 8; +const REQUEST_TIMEOUT_MS = 60_000; +const MAX_ATTEMPTS = 4; + +// Returns the parsed body, or null when the Hub said the thing does not exist +// or never answered. Both mean "unconfirmed", which the builder treats as a +// problem; retries cover the transient case. +async function fetchJSON(url) { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(url, { + headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (response.status === 429 || response.status >= 500) { + const retryAfter = Number.parseInt(response.headers.get("retry-after") ?? "", 10); + await new Promise((done) => setTimeout(done, Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000 * 2 ** attempt)); + continue; + } + if (!response.ok) { + console.error(`${url} responded ${response.status}`); + return null; + } + return { body: await response.json(), next: nextPage(response.headers.get("link")) }; + } catch (error) { + if (attempt === MAX_ATTEMPTS) { + console.error(`${url} failed: ${error}`); + return null; + } + } + } + console.error(`${url} kept failing`); + return null; +} + +// The tree endpoint pages large repositories through a Link header. +function nextPage(link) { + const match = /<([^>]+)>;\s*rel="next"/.exec(link ?? ""); + return match ? match[1] : undefined; +} + +async function tree(repo) { + const files = []; + let url = `${HUB}/api/models/${repo}/tree/main?recursive=true`; + while (url) { + const page = await fetchJSON(url); + if (!page || !Array.isArray(page.body)) return null; + files.push(...page.body.filter((entry) => entry.type === "file")); + url = page.next; + } + return files; +} + +async function stats(repo) { + const page = await fetchJSON(`${HUB}/api/models/${repo}`); + const downloads = Number(page?.body?.downloads); + const likes = Number(page?.body?.likes); + if (!Number.isFinite(downloads) || !Number.isFinite(likes)) return null; + return { downloads: Math.max(0, Math.trunc(downloads)), likes: Math.max(0, Math.trunc(likes)) }; +} + +async function mapLimited(items, work) { + const results = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => { + while (next < items.length) { + const index = next++; + results[index] = await work(items[index]); + } + }); + await Promise.all(workers); + return results; +} + +async function main() { + const { values } = parseArgs({ + options: { out: { type: "string", default: "v1/featured-models.json" }, force: { type: "boolean", default: false } }, + }); + const outputPath = resolve(repoRoot, values.out); + const curation = { models: MODELS, collections: COLLECTIONS }; + const ollama = indexOllama(JSON.parse(await readFile(resolve(repoRoot, "v1/ollama-models.json"), "utf8"))); + let previous; + try { + previous = JSON.parse(await readFile(outputPath, "utf8")); + } catch { + previous = undefined; + } + + const refs = hubReferences(curation); + const [trees, repoStats, bases, configs] = await Promise.all([ + mapLimited(refs.repos, tree), + mapLimited(refs.repos, stats), + mapLimited(refs.bases, async (repo) => (await fetchJSON(`${HUB}/api/models/${repo}`))?.body ?? null), + mapLimited(refs.configs, async (repo) => (await fetchJSON(`${HUB}/${repo}/resolve/main/config.json`))?.body ?? null), + ]); + const zip = (keys, list) => new Map(keys.map((key, index) => [key, list[index]])); + const hub = { + trees: zip(refs.repos, trees), + stats: zip(refs.repos, repoStats), + bases: zip(refs.bases, bases), + configs: zip(refs.configs, configs), + }; + + const generatedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + const { artifact, problems, notes } = buildArtifact({ curation, ollama, hub, generatedAt }); + for (const note of notes) console.log(`note: ${note}`); + if (problems.length > 0) { + for (const problem of problems) console.error(`refusing to publish: ${problem}`); + process.exit(1); + } + + const counts = `${artifact.models.length} models, ${artifact.collections.length} collections, ` + + `${artifact.models.reduce((sum, model) => sum + model.installs.length, 0)} installs`; + if (!values.force && previous && JSON.stringify(stableView(previous)) === JSON.stringify(stableView(artifact))) { + console.log(`unchanged: ${counts}`); + return; + } + await writeFile(outputPath, serialize(artifact), "utf8"); + console.log(`wrote ${values.out}: ${counts}`); +} + +await main(); diff --git a/scripts/featured-report.mjs b/scripts/featured-report.mjs new file mode 100644 index 0000000..32f2413 --- /dev/null +++ b/scripts/featured-report.mjs @@ -0,0 +1,97 @@ +// Lists mlx-community repositories that may be newer versions of featured +// models, as Markdown for a maintainer to review. It changes nothing: a lead +// becomes a featured model only through a reviewed edit of +// scripts/featured-curation.mjs. +// +// Run: node scripts/featured-report.mjs >> "$GITHUB_STEP_SUMMARY" +// +// Each curated model may name a `watch` term ("GLM-", "MiniMax-M"). A lead is a +// repository whose name starts with that term, uploaded after the newest +// featured repository that shares the term, that is not another quantization of +// a featured model or an obvious derivative (abliterated, distilled, merged), +// and that people have started to download. It is a heuristic: expect some +// noise, such as other sizes of a featured family. + +import { COLLECTIONS, MODELS } from "./featured-curation.mjs"; + +const HUB = "https://huggingface.co"; +const USER_AGENT = "msty-model-catalog/1.0 (+https://github.com/cloudstack-llc/catalogs)"; +const AUTHOR = "mlx-community"; +const MIN_DOWNLOADS = 200; +const PER_TERM = 5; +const DERIVATIVE = /abliterat|obliterat|uncensor|heretic|distill|merge|reap|claude|opus|gemini|deckard|aeon|sft|lora|test|nsfw|caption/i; +// Not chat models, so never a newer version of one. +const OTHER_JOB = /music|speech|asr|tts|diariz|parse|audio|whisper/i; +// Quantization and packaging suffixes, stripped so "GLM-5.3-8bit" and +// "GLM-5.3-4bit" read as the same model. +const PACKAGING = /[-_](?:mlx|\d+(?:\.\d+)?-?bits?|mxfp\d+|nvfp\d+|fp\d+|bf16|q\d+|dq\d.*|mixed.*|dwq.*|optiq.*|oq\d.*|gs\d+|qat|mtp|dflash\d*)$/i; + +// The repository name without its owner or an uploader's "publisher_" prefix. +function bare(repo) { + return repo.slice(repo.indexOf("/") + 1).replace(/^[a-z0-9-]+_/i, "").toLowerCase(); +} + +function stem(repo) { + let name = bare(repo); + for (let previous = ""; previous !== name;) { + previous = name; + name = name.replace(PACKAGING, ""); + } + return name; +} + +async function getJSON(url) { + try { + const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(30_000) }); + return response.ok ? await response.json() : null; + } catch { + return null; + } +} + +async function main() { + const featuredRepos = MODELS.flatMap((model) => [model.mlx, model.gguf]).filter(Boolean); + const featuredStems = new Set(featuredRepos.map(stem)); + const byTerm = new Map(); + for (const model of MODELS) { + if (model.watch && model.mlx) byTerm.set(model.watch, [...(byTerm.get(model.watch) ?? []), model]); + } + + const lines = ["## Featured models: possible newer versions", ""]; + let leads = 0; + for (const [term, models] of byTerm) { + const created = await Promise.all(models.map(async (model) => (await getJSON(`${HUB}/api/models/${model.mlx}`))?.createdAt)); + const newest = created.filter(Boolean).sort().at(-1); + if (!newest) { + lines.push(`- \`${term}\`: featured repositories unreadable, skipped`); + continue; + } + const found = await getJSON( + `${HUB}/api/models?author=${AUTHOR}&search=${encodeURIComponent(term)}&sort=createdAt&direction=-1&limit=100`, + ); + const candidates = (found ?? []) + .filter((repo) => repo.createdAt > newest && (repo.downloads ?? 0) >= MIN_DOWNLOADS) + .filter((repo) => bare(repo.id).startsWith(term.toLowerCase())) + .filter((repo) => !DERIVATIVE.test(repo.id) && !OTHER_JOB.test(repo.id) && !featuredStems.has(stem(repo.id))) + .filter((repo, index, all) => all.findIndex((other) => stem(other.id) === stem(repo.id)) === index) + .sort((a, b) => b.downloads - a.downloads) + .slice(0, PER_TERM); + if (candidates.length === 0) continue; + leads += candidates.length; + lines.push(`### \`${term}\` (featured: ${models.map((model) => model.id).join(", ")})`, ""); + for (const repo of candidates) { + lines.push(`- [${repo.id}](${HUB}/${repo.id}): ${repo.downloads} downloads, uploaded ${repo.createdAt.slice(0, 10)}`); + } + lines.push(""); + } + if (leads === 0) lines.push("No leads today."); + const cluster = COLLECTIONS.find((collection) => collection.id === "cluster"); + lines.push( + "", + `Before adding a lead to the \`${cluster.id}\` collection, check that the Msty Nexus cluster worker can split its ` + + "model family and that it loads without trust_remote_code.", + ); + console.log(lines.join("\n")); +} + +await main(); diff --git a/scripts/featured-verify.mjs b/scripts/featured-verify.mjs new file mode 100644 index 0000000..578fca1 --- /dev/null +++ b/scripts/featured-verify.mjs @@ -0,0 +1,30 @@ +// Validates the committed featured list without using the network: its shape, +// and that it still matches scripts/featured-curation.mjs. +// +// Run: node scripts/featured-verify.mjs [path] + +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { COLLECTIONS, MODELS } from "./featured-curation.mjs"; +import { serialize, validateCuration, validateFeatured } from "./featured.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const path = resolve(repoRoot, process.argv[2] ?? "v1/featured-models.json"); +const text = await readFile(path, "utf8"); +const artifact = JSON.parse(text); +const curation = { models: MODELS, collections: COLLECTIONS }; +const problems = [...validateCuration(curation), ...validateFeatured(artifact, curation)]; + +if (serialize(artifact) !== text) { + problems.push("file must use the canonical two-space JSON format"); +} + +if (problems.length > 0) { + for (const problem of problems) console.error(`invalid artifact: ${problem}`); + process.exit(1); +} + +const installs = artifact.models.reduce((total, model) => total + model.installs.length, 0); +console.log(`ok: ${artifact.models.length} featured models, ${artifact.collections.length} collections, ${installs} installs`); diff --git a/scripts/featured.mjs b/scripts/featured.mjs new file mode 100644 index 0000000..055fc1c --- /dev/null +++ b/scripts/featured.mjs @@ -0,0 +1,414 @@ +// Pure shaping and validation for v1/featured-models.json. No I/O, so every +// rule can be tested against fixtures; featured-generate.mjs does the fetching. +// +// Nothing here is typed from memory. Ollama tags, sizes, and context windows +// come from v1/ollama-models.json; Hugging Face repositories, GGUF file names, +// and byte counts come from the Hub API. A reference that cannot be confirmed +// is a problem, and a run with problems publishes nothing, because a featured +// row with a dead download behind it is worse than one fewer row. + +export const SCHEMA_VERSION = 1; +export const TIERS = ["frontier", "excellent", "strong", "good"]; +export const RUNTIMES = ["ollama", "llamacpp", "mlx"]; +// Msty Nexus Cluster Advisor reads this collection by id. +export const CLUSTER_COLLECTION = "cluster"; + +// Roughly 2.8 bits per weight. Q3 and up clear it; IQ1 and IQ2 do not, and +// neither does anything that is not the weights at all. +const MIN_BYTES_PER_PARAMETER = 0.35; + +const QUANT = /(?:^|[._-])(I?Q[1-8](?:_[A-Z0-9]+){0,3}|BF16|F16|F32|MXFP4)(?:[._-]|$)/i; +const SHARD = /-\d{5}-of-\d{5}/; +// A count in the publisher's repository name: Qwen3.5-397B-A17B. +const NAMED_PARAMETERS = /-(\d+(?:\.\d+)?)B(?:-A\d+(?:\.\d+)?B)?$/; + +// Python's round(), which the original builder used: halves go to the even +// neighbour. Kept so the port labels "30.5b" as 30B, not 31B. +function roundHalfEven(value) { + const floor = Math.floor(value); + const diff = value - floor; + if (diff > 0.5) return floor + 1; + if (diff < 0.5) return floor; + return floor % 2 === 0 ? floor : floor + 1; +} + +/** + * `4.9GB` as Ollama publishes it, in bytes; 0 when unreadable. + * + * A tag that resolves to different files per platform is published as a range, + * "6.6GB - 7.6GB" (qwen3.5:9b is Q4_K_M on one platform and NVFP4 on another). + * The upper end is used: size_bytes is what the download may cost, and a fit + * check that overestimates by a gigabyte is safer than one that underestimates. + */ +export function parseSize(value) { + const parts = String(value ?? "").split(/\s*-\s*/); + const raw = parts[parts.length - 1].toUpperCase().replace(/\s/g, ""); + for (const [suffix, factor] of [["TB", 1e12], ["GB", 1e9], ["MB", 1e6], ["KB", 1e3], ["B", 1]]) { + if (raw.endsWith(suffix)) { + const number = Number.parseFloat(raw.slice(0, -suffix.length)); + return Number.isFinite(number) ? Math.round(number * factor) : 0; + } + } + return 0; +} + +/** `256K` or `1M` as Ollama publishes it, in tokens; 0 when unreadable. */ +export function parseContext(value) { + let raw = String(value ?? "").toUpperCase().replace(/\s/g, ""); + let multiplier = 1; + if (raw.endsWith("M")) { + multiplier = 1024 * 1024; + raw = raw.slice(0, -1); + } else if (raw.endsWith("K")) { + multiplier = 1024; + raw = raw.slice(0, -1); + } + const number = Number(raw); + return raw !== "" && Number.isFinite(number) ? Math.round(number * multiplier) : 0; +} + +/** + * `8.03b` or `308m` as Ollama publishes it, rounded to something readable. + * + * Ollama reports sub-billion models in millions, and `1000m` for a model + * everyone calls 1B. Both are normalized so a column of parameter counts can be + * compared down the page. + */ +export function parametersLabel(raw) { + const text = String(raw ?? "").trim().toLowerCase(); + const value = Number.parseFloat(text.slice(0, -1)); + if (!Number.isFinite(value)) return ""; + if (text.endsWith("m")) { + return value >= 1000 ? parametersLabel(`${value / 1000}b`) : `${roundHalfEven(value)}M`; + } + if (!text.endsWith("b")) return ""; + if (value >= 10) return `${roundHalfEven(value)}B`; + return `${value.toFixed(1)}B`.replace(".0B", "B"); +} + +/** The parameter count an Ollama tag publishes, as a number. */ +export function parameterCount(tag) { + const raw = String(tag?.model_info?.parameters ?? "").trim().toLowerCase(); + const scale = { b: 1e9, m: 1e6 }[raw.slice(-1)]; + const value = Number.parseFloat(raw.slice(0, -1)); + return scale && Number.isFinite(value) ? value * scale : 0; +} + +const basename = (path) => path.slice(path.lastIndexOf("/") + 1); +const folder = (path) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""); + +/** + * The single GGUF a first-time download should get, or undefined. + * + * Three kinds of `.gguf` are not the model, and picking one produces a download + * that completes and then does not work: `mmproj-*` is a vision projector, + * `mtp-*` a multi-token-prediction draft head, `imatrix*` calibration data. A + * `-00001-of-000NN` shard is one slice of a model this format cannot describe, + * because a featured row promises one click. Undefined is a real answer: some + * repositories publish only shards. + */ +export function pickGguf(files, parameters) { + let ggufs = files.filter((entry) => { + const name = basename(entry.path).toLowerCase(); + return name.endsWith(".gguf") && !name.startsWith("mmproj") && !name.startsWith("mtp-") + && !name.includes("imatrix") && !SHARD.test(name); + }); + // Bytes per parameter catches the oddities a name list has not met yet: a + // projector, a calibration matrix, and a draft head all land far below any + // real quantization, and so does a 1-bit quant nobody should get by default. + if (parameters) { + ggufs = ggufs.filter((entry) => entry.size / parameters >= MIN_BYTES_PER_PARAMETER); + } + if (ggufs.length === 0) return undefined; + // Q4_K_M first: the quantization that trades the least quality for the most + // size, and the one every other tool defaults to. + for (const want of ["q4_k_m", "q4_k.", "q4_k_s", "mxfp4", "q4_0", "q8_0", "f16", "bf16"]) { + const match = ggufs.find((entry) => basename(entry.path).toLowerCase().includes(want)); + if (match) return match; + } + return ggufs.reduce((smallest, entry) => (entry.size < smallest.size ? entry : smallest)); +} + +/** + * The `mmproj-*` file that belongs beside the chosen weights, or undefined. + * + * The largest wins: a projector is a small fraction of the download, and the + * quality lost by quantizing it is not worth the megabytes saved. Repositories + * that sort quantizations into folders keep the matching projector in the same + * folder or at the root. + */ +export function pickProjector(files, weights) { + const directory = folder(weights.path); + const projectors = files.filter((entry) => { + const name = basename(entry.path).toLowerCase(); + if (!name.startsWith("mmproj") || !name.endsWith(".gguf")) return false; + return !directory || [directory, ""].includes(folder(entry.path)); + }); + if (projectors.length === 0) return undefined; + return projectors.reduce((largest, entry) => (entry.size > largest.size ? entry : largest)); +} + +/** Indexes v1/ollama-models.json by `model:tag`. */ +export function indexOllama(catalog) { + const index = new Map(); + for (const model of catalog.models) { + for (const tag of model.tags) index.set(`${model.name}:${tag.tag}`, tag); + } + return index; +} + +/** Every Hugging Face repository the curation names, for the fetcher. */ +export function hubReferences(curation) { + const repos = new Set(); + const bases = new Set(); + const configs = new Set(); + for (const model of curation.models) { + for (const repo of [model.gguf, model.mlx]) if (repo) repos.add(repo); + if (!model.ollama && model.base) { + if (!NAMED_PARAMETERS.test(model.base)) bases.add(model.base); + if (model.mlx) configs.add(model.mlx); + } + } + return { repos: [...repos].sort(), bases: [...bases].sort(), configs: [...configs].sort() }; +} + +/** Problems with the curated input itself, before anything is fetched. */ +export function validateCuration(curation) { + const problems = []; + const ids = new Set(); + for (const model of curation.models) { + if (!model.id || !model.name) problems.push(`a curated model has no id or name`); + if (ids.has(model.id)) problems.push(`${model.id}: curated twice`); + ids.add(model.id); + if (!TIERS.includes(model.tier)) problems.push(`${model.id}: tier ${JSON.stringify(model.tier)} is not one of ${TIERS.join(", ")}`); + if (!model.ollama && !model.gguf && !model.mlx) problems.push(`${model.id}: names no install`); + if (!model.ollama && !model.base) problems.push(`${model.id}: no ollama tag and no base repository`); + } + for (const collection of curation.collections) { + const missing = collection.models.filter((id) => !ids.has(id)); + if (missing.length > 0) problems.push(`collection ${collection.id}: unresolved ${missing.join(", ")}`); + if (collection.models.length === 0) problems.push(`collection ${collection.id}: empty`); + } + if (!curation.collections.some((collection) => collection.id === CLUSTER_COLLECTION)) { + problems.push(`no ${CLUSTER_COLLECTION} collection`); + } + return problems; +} + +/** + * Builds the artifact from the curation and what the fetcher read. + * + * hub.trees, hub.stats, hub.bases, and hub.configs map a repository to its + * file list, {downloads, likes}, model document, and config.json; a missing or + * null entry means it could not be read. Returns the artifact, the problems + * that must block publishing, and notes that do not. + */ +export function buildArtifact({ curation, ollama, hub, generatedAt }) { + const problems = validateCuration(curation); + const notes = []; + const models = []; + + for (const entry of curation.models) { + const capabilities = [...entry.capabilities]; + const installs = []; + let parameters = ""; + let context = 0; + let downloads = 0; + let likes = 0; + + const tag = entry.ollama ? ollama.get(entry.ollama) : undefined; + if (entry.ollama && !tag) { + problems.push(`${entry.id}: ollama tag ${entry.ollama} is not in the catalog`); + } else if (tag) { + const info = tag.model_info ?? {}; + parameters = parametersLabel(info.parameters); + context = parseContext(info.contextWindow); + const install = { runtime: "ollama", model: entry.ollama.replace(/:latest$/, "") }; + const size = parseSize(tag.size); + // A size the parser cannot read is a parser bug, not a missing value: + // Ollama renders one for every downloadable tag. + if (size) install.size_bytes = size; + else problems.push(`${entry.id}: ollama size ${JSON.stringify(tag.size)} is unreadable`); + if (info.quantization) install.quantization = info.quantization; + installs.push(install); + } else if (entry.base) { + const named = NAMED_PARAMETERS.exec(entry.base); + // A count in the repository name wins over the Hub's safetensors total, + // which also counts vision and MTP weights and so would label the row + // with a size nobody calls the model. + const total = named ? Number(named[1]) * 1e9 : hub.bases.get(entry.base)?.safetensors?.total ?? 0; + const config = hub.configs.get(entry.mlx) ?? {}; + context = config.max_position_embeddings ?? config.text_config?.max_position_embeddings ?? 0; + if (!total || !context) { + problems.push(`${entry.id}: parameters or context unreadable from ${entry.base} and ${entry.mlx}`); + } + parameters = total ? parametersLabel(`${total / 1e9}b`) : ""; + } + + const popularity = (repo) => { + const stats = hub.stats.get(repo); + if (!stats) { + // Popularity is decoration, so a stats hiccup never blocks a build. + notes.push(`${entry.id}: ${repo} stats are unreadable, so it ships without numbers`); + return; + } + downloads = Math.max(downloads, stats.downloads); + likes = Math.max(likes, stats.likes); + }; + + if (entry.gguf) { + const files = hub.trees.get(entry.gguf); + if (!files) { + problems.push(`${entry.id}: gguf repo ${entry.gguf} is unreachable`); + } else { + popularity(entry.gguf); + const chosen = pickGguf(files, parameterCount(tag)); + if (!chosen) { + notes.push(`${entry.id}: ${entry.gguf} publishes only sharded weights, so it gets no llama.cpp variant`); + } else { + const quantization = QUANT.exec(chosen.path); + const projector = pickProjector(files, chosen); + installs.push({ + runtime: "llamacpp", + repository: entry.gguf, + file: chosen.path, + ...(quantization ? { quantization: quantization[1].toUpperCase() } : {}), + // The projector is part of the download, so it is part of the size. + size_bytes: chosen.size + (projector ? projector.size : 0), + ...(projector ? { projector: projector.path } : {}), + }); + // A publisher shipping a projector is stating the model is + // multimodal, which is better evidence than a curator's memory. + if (projector && !capabilities.includes("vision")) capabilities.push("vision"); + if (capabilities.includes("vision") && !projector) { + problems.push(`${entry.id}: ${entry.gguf} has no mmproj beside ${chosen.path}, so llama.cpp could not see`); + } + } + } + } + + if (entry.mlx) { + const files = hub.trees.get(entry.mlx); + if (!files) { + problems.push(`${entry.id}: mlx repo ${entry.mlx} is unreachable`); + } else { + popularity(entry.mlx); + // A snapshot pull downloads the whole repository. + installs.push({ runtime: "mlx", repository: entry.mlx, size_bytes: files.reduce((sum, file) => sum + file.size, 0) }); + } + } + + if (installs.length === 0) { + problems.push(`${entry.id}: no usable install`); + continue; + } + + const model = { id: entry.id, name: entry.name, publisher: entry.publisher, summary: entry.summary }; + if (parameters) model.parameters = parameters; + if (context) model.context = context; + // The per-field maximum across the model's repositories. Nexus refreshes + // these live; the published numbers are only the fallback. + if (downloads) model.downloads = downloads; + if (likes) model.likes = likes; + model.capabilities = capabilities; + if (entry.coding) model.tags = ["coding"]; + model.tier = entry.tier; + model.installs = installs; + models.push(model); + } + + const known = new Set(models.map((model) => model.id)); + const collections = curation.collections.map((collection) => ({ + id: collection.id, + title: collection.title, + description: collection.description, + models: collection.models.filter((id) => known.has(id)), + })); + + return { + artifact: { schema_version: SCHEMA_VERSION, generated_at: generatedAt, collections, models }, + problems: [...new Set(problems)], + notes, + }; +} + +/** + * The artifact without the fields that move on their own. generated_at moves + * every run, and popularity every day; neither is a reason to publish, so the + * history keeps meaning "the featured list changed". + */ +export function stableView(artifact) { + return { + collections: artifact.collections, + models: artifact.models.map(({ downloads, likes, ...rest }) => rest), + }; +} + +export function serialize(artifact) { + return `${JSON.stringify(artifact, null, 2)}\n`; +} + +/** + * Problems with a published artifact, without the network: its shape, and + * whether it still matches the curation (a curation edit without a rebuild + * fails here). + */ +export function validateFeatured(artifact, curation) { + const problems = []; + if (artifact.schema_version !== SCHEMA_VERSION) problems.push(`schema_version is not ${SCHEMA_VERSION}`); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(artifact.generated_at ?? "")) { + problems.push("generated_at is not an RFC 3339 UTC timestamp"); + } + + const byId = new Map(artifact.models.map((model) => [model.id, model])); + const curated = new Map(curation.models.map((model) => [model.id, model])); + if (byId.size !== artifact.models.length) problems.push("model ids are not unique"); + const order = (list) => JSON.stringify(list.map((model) => model.id)); + if (order(artifact.models) !== order(curation.models)) { + problems.push("models differ from the curation; rebuild with scripts/featured-generate.mjs"); + } + + for (const model of artifact.models) { + const want = curated.get(model.id); + if (!want) continue; + for (const field of ["name", "publisher", "summary", "tier"]) { + if (model[field] !== want[field]) problems.push(`${model.id}: ${field} differs from the curation`); + } + if (JSON.stringify(model.tags) !== JSON.stringify(want.coding ? ["coding"] : undefined)) { + problems.push(`${model.id}: tags differ from the curation`); + } + if (!want.capabilities.every((capability) => model.capabilities?.includes(capability))) { + problems.push(`${model.id}: capabilities differ from the curation`); + } + if (!Array.isArray(model.installs) || model.installs.length === 0) { + problems.push(`${model.id}: no installs`); + continue; + } + for (const install of model.installs) { + if (!RUNTIMES.includes(install.runtime)) problems.push(`${model.id}: unknown runtime ${install.runtime}`); + if (install.runtime === "ollama" && !install.model) problems.push(`${model.id}: ollama install has no tag`); + if (install.runtime !== "ollama" && !install.repository) problems.push(`${model.id}: ${install.runtime} install has no repository`); + if (install.runtime === "llamacpp" && !install.file) problems.push(`${model.id}: llama.cpp install has no file`); + if (!Number.isSafeInteger(install.size_bytes) || install.size_bytes <= 0) { + problems.push(`${model.id}: ${install.runtime} install has no size`); + } + } + const repos = (runtime) => model.installs.filter((install) => install.runtime === runtime).map((install) => install.repository ?? install.model); + if (want.mlx && !repos("mlx").includes(want.mlx)) problems.push(`${model.id}: mlx install is not ${want.mlx}`); + if (want.ollama && !repos("ollama").includes(want.ollama.replace(/:latest$/, ""))) { + problems.push(`${model.id}: ollama install is not ${want.ollama}`); + } + } + + const wantCollections = curation.collections.map(({ id, title, description, models }) => ({ id, title, description, models })); + if (JSON.stringify(artifact.collections) !== JSON.stringify(wantCollections)) { + problems.push("collections differ from the curation; rebuild with scripts/featured-generate.mjs"); + } + const cluster = artifact.collections.find((collection) => collection.id === CLUSTER_COLLECTION); + for (const id of cluster?.models ?? []) { + if (!byId.get(id)?.installs?.some((install) => install.runtime === "mlx")) { + problems.push(`${id}: in the ${CLUSTER_COLLECTION} collection without an mlx install`); + } + } + return problems; +} diff --git a/scripts/featured.test.mjs b/scripts/featured.test.mjs new file mode 100644 index 0000000..c4c4edb --- /dev/null +++ b/scripts/featured.test.mjs @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { COLLECTIONS, MODELS } from "./featured-curation.mjs"; +import { + buildArtifact, + parametersLabel, + parseSize, + pickGguf, + stableView, + validateCuration, + validateFeatured, +} from "./featured.mjs"; + +const GB = 1e9; + +function curation() { + return { + models: [ + { + id: "small", name: "Small", publisher: "P", summary: "S.", capabilities: ["chat"], + ollama: "small:8b", gguf: "org/Small-GGUF", mlx: "mlx-community/Small-4bit", tier: "good", coding: true, + }, + { + id: "huge", name: "Huge", publisher: "P", summary: "S.", capabilities: ["chat"], + mlx: "mlx-community/Huge-4bit", base: "org/Huge-400B-A20B", tier: "frontier", + }, + ], + collections: [ + { id: "start-here", title: "Start", description: "D.", models: ["small"] }, + { id: "cluster", title: "Cluster", description: "D.", models: ["huge"] }, + ], + }; +} + +function inputs() { + return { + curation: curation(), + ollama: new Map([["small:8b", { size: "4.9GB - 5.2GB", model_info: { parameters: "8.19b", contextWindow: "40K", quantization: "Q4_K_M" } }]]), + hub: { + trees: new Map([ + ["org/Small-GGUF", [ + { path: "Small-Q4_K_M.gguf", size: 5 * GB }, + { path: "Small-Q8_0.gguf", size: 9 * GB }, + { path: "mmproj-F16.gguf", size: 0.6 * GB }, + ]], + ["mlx-community/Small-4bit", [{ path: "model.safetensors", size: 4 * GB }, { path: "config.json", size: 1000 }]], + ["mlx-community/Huge-4bit", [{ path: "model-00001-of-00002.safetensors", size: 100 * GB }, { path: "model-00002-of-00002.safetensors", size: 120 * GB }]], + ]), + stats: new Map([["org/Small-GGUF", { downloads: 10, likes: 2 }], ["mlx-community/Small-4bit", { downloads: 5, likes: 7 }]]), + bases: new Map(), + configs: new Map([["mlx-community/Huge-4bit", { text_config: { max_position_embeddings: 262144 } }]]), + }, + generatedAt: "2026-10-18T00:00:00Z", + }; +} + +test("an Ollama size published as a range uses the upper end", () => { + assert.equal(parseSize("6.6GB - 7.6GB"), 7.6 * GB); + assert.equal(parseSize("14GB"), 14 * GB); + assert.equal(parseSize("523MB"), 523e6); + assert.equal(parseSize("N/A"), 0); +}); + +test("parameter labels match the original builder's rounding", () => { + assert.equal(parametersLabel("8.95b"), "8.9B"); + assert.equal(parametersLabel("30.5b"), "30B"); + assert.equal(parametersLabel("1000m"), "1B"); + assert.equal(parametersLabel("752m"), "752M"); +}); + +test("the GGUF pick skips projectors, shards, draft heads, and too-small files", () => { + const files = [ + { path: "mmproj-F16.gguf", size: 0.6 * GB }, + { path: "mtp-Q4_K_M.gguf", size: 0.2 * GB }, + { path: "M-Q4_K_M-00001-of-00002.gguf", size: 3 * GB }, + { path: "M-IQ1_S.gguf", size: 1.5 * GB }, + { path: "M-Q8_0.gguf", size: 9 * GB }, + ]; + assert.equal(pickGguf(files, 8 * GB).path, "M-Q8_0.gguf"); + assert.equal(pickGguf(files.slice(0, 3), 8 * GB), undefined); +}); + +test("builds rows from Ollama and from the Hub, with tier, tags, and a projector", () => { + const { artifact, problems } = buildArtifact(inputs()); + assert.deepEqual(problems, []); + const [small, huge] = artifact.models; + assert.equal(small.parameters, "8.2B"); + assert.equal(small.context, 40960); + assert.deepEqual(small.tags, ["coding"]); + assert.equal(small.tier, "good"); + assert.deepEqual(small.capabilities, ["chat", "vision"]); + assert.deepEqual(small.installs.map((install) => install.size_bytes), [5.2 * GB, 5.6 * GB, 4 * GB + 1000]); + assert.equal(small.installs[1].projector, "mmproj-F16.gguf"); + assert.equal(small.downloads, 10); + assert.equal(small.likes, 7); + assert.equal(huge.parameters, "400B"); + assert.equal(huge.context, 262144); + assert.equal(huge.tier, "frontier"); + assert.deepEqual(huge.installs, [{ runtime: "mlx", repository: "mlx-community/Huge-4bit", size_bytes: 220 * GB }]); +}); + +test("an unconfirmed reference or an unreadable size blocks publishing", () => { + const unreadable = inputs(); + unreadable.ollama.get("small:8b").size = "about five gigs"; + assert.match(buildArtifact(unreadable).problems.join("\n"), /ollama size .* is unreadable/); + + const missingTag = inputs(); + missingTag.ollama.clear(); + assert.match(buildArtifact(missingTag).problems.join("\n"), /not in the catalog/); + + const deadRepo = inputs(); + deadRepo.hub.trees.set("mlx-community/Huge-4bit", null); + assert.match(buildArtifact(deadRepo).problems.join("\n"), /Huge-4bit is unreachable/); + + const noContext = inputs(); + noContext.hub.configs.clear(); + assert.match(buildArtifact(noContext).problems.join("\n"), /parameters or context unreadable/); +}); + +test("a stats failure is a note, never a problem", () => { + const input = inputs(); + input.hub.stats.clear(); + const { problems, notes } = buildArtifact(input); + assert.deepEqual(problems, []); + assert.equal(notes.length, 3); +}); + +test("the curation rejects bad tiers, dangling references, and a missing cluster collection", () => { + const bad = curation(); + bad.models[0].tier = "great"; + bad.collections = [{ id: "start-here", title: "S", description: "D.", models: ["small", "gone"] }]; + const problems = validateCuration(bad).join("\n"); + assert.match(problems, /tier "great"/); + assert.match(problems, /unresolved gone/); + assert.match(problems, /no cluster collection/); +}); + +test("popularity and generated_at alone do not count as a change", () => { + const first = buildArtifact(inputs()).artifact; + const later = inputs(); + later.generatedAt = "2026-10-19T00:00:00Z"; + later.hub.stats.set("org/Small-GGUF", { downloads: 999, likes: 99 }); + assert.deepEqual(stableView(buildArtifact(later).artifact), stableView(first)); +}); + +test("the published file must match the curation", () => { + const { artifact } = buildArtifact(inputs()); + assert.deepEqual(validateFeatured(artifact, curation()), []); + const edited = curation(); + edited.models[1].tier = "excellent"; + assert.match(validateFeatured(artifact, edited).join("\n"), /huge: tier differs/); +}); + +test("the committed featured list is valid and matches the curation", async () => { + const committed = JSON.parse(await readFile(new URL("../v1/featured-models.json", import.meta.url), "utf8")); + const real = { models: MODELS, collections: COLLECTIONS }; + assert.deepEqual(validateCuration(real), []); + assert.deepEqual(validateFeatured(committed, real), []); +}); diff --git a/v1/featured-models.json b/v1/featured-models.json index 67ccca9..c9c8718 100644 --- a/v1/featured-models.json +++ b/v1/featured-models.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "generated_at": "2026-10-18T22:04:21Z", + "generated_at": "2026-10-18T22:26:43Z", "collections": [ { "id": "start-here", @@ -112,8 +112,10 @@ "name": "Qwen3.5 9B", "publisher": "Qwen", "summary": "Strong all-round chat with a very long context. A good first model.", - "parameters": "9.7B", + "parameters": "8.9B", "context": 262144, + "downloads": 1287434, + "likes": 1006, "capabilities": [ "chat", "tools", @@ -125,7 +127,7 @@ { "runtime": "ollama", "model": "qwen3.5:9b", - "size_bytes": 6600000000, + "size_bytes": 7600000000, "quantization": "Q4_K_M" }, { @@ -150,6 +152,8 @@ "summary": "Frontier-level quality at a size a laptop can hold.", "parameters": "12B", "context": 262144, + "downloads": 1627970, + "likes": 918, "capabilities": [ "chat", "tools", @@ -160,7 +164,7 @@ { "runtime": "ollama", "model": "gemma4:12b", - "size_bytes": 7600000000, + "size_bytes": 8000000000, "quantization": "Q4_K_M" }, { @@ -185,6 +189,8 @@ "summary": "Built for fast, reliable tool calling on consumer hardware.", "parameters": "8.5B", "context": 128000, + "downloads": 184715, + "likes": 326, "capabilities": [ "chat", "tools" @@ -218,6 +224,8 @@ "summary": "Mistral's edge family, sized to run on a single machine.", "parameters": "8.9B", "context": 262144, + "downloads": 43089, + "likes": 100, "capabilities": [ "chat", "tools" @@ -251,6 +259,8 @@ "summary": "Qwen's flagship open model, strong on coding and research.", "parameters": "27B", "context": 262144, + "downloads": 2248115, + "likes": 213, "capabilities": [ "chat", "tools", @@ -273,7 +283,7 @@ "repository": "ggml-org/Qwen3.8-27B-GGUF", "file": "Qwen3.8-27B-Q4_K_M.gguf", "quantization": "Q4_K_M", - "size_bytes": 19905016320, + "size_bytes": 19905016416, "projector": "mmproj-Qwen3.8-27B-BF16.gguf" }, { @@ -290,6 +300,8 @@ "summary": "A mixture-of-experts model tuned for agentic coding and long thinking.", "parameters": "36B", "context": 262144, + "downloads": 1237002, + "likes": 1700, "capabilities": [ "chat", "tools", @@ -329,6 +341,8 @@ "summary": "The strongest of the 30B class, with a 198K context window.", "parameters": "30B", "context": 202752, + "downloads": 29187, + "likes": 71, "capabilities": [ "chat", "tools", @@ -366,6 +380,8 @@ "summary": "A 30B mixture-of-experts model with a one-million-token context.", "parameters": "33B", "context": 1048576, + "downloads": 182911, + "likes": 35, "capabilities": [ "chat", "tools", @@ -400,6 +416,8 @@ "summary": "OpenAI's open-weight model for reasoning and agentic work.", "parameters": "21B", "context": 131072, + "downloads": 203101, + "likes": 182, "capabilities": [ "chat", "tools", @@ -434,6 +452,8 @@ "summary": "Meta's open model for always-on local agents.", "parameters": "28B", "context": 131072, + "downloads": 787379, + "likes": 376, "capabilities": [ "chat", "tools", @@ -469,6 +489,8 @@ "summary": "A self-improving model for agentic coding, small enough for a laptop.", "parameters": "8.9B", "context": 262144, + "downloads": 2301496, + "likes": 692, "capabilities": [ "chat", "code", @@ -506,6 +528,8 @@ "summary": "The larger Ornith, for coding work that needs the headroom.", "parameters": "35B", "context": 262144, + "downloads": 1494421, + "likes": 1061, "capabilities": [ "chat", "code", @@ -543,6 +567,8 @@ "summary": "Cohere's developer model: 30B mixture-of-experts, 488K context.", "parameters": "30B", "context": 499712, + "downloads": 234778, + "likes": 147, "capabilities": [ "chat", "code", @@ -580,6 +606,8 @@ "summary": "A 33B mixture-of-experts model with 3B active, built for long-horizon work.", "parameters": "33B", "context": 262144, + "downloads": 195280, + "likes": 99, "capabilities": [ "chat", "code", @@ -617,6 +645,8 @@ "summary": "Qwen's coding-focused model for agentic editing across a codebase.", "parameters": "80B", "context": 262144, + "downloads": 142694, + "likes": 886, "capabilities": [ "chat", "code", @@ -654,6 +684,8 @@ "summary": "Shows its working, and still fits in under a gigabyte.", "parameters": "1.2B", "context": 128000, + "downloads": 10355, + "likes": 119, "capabilities": [ "chat", "reasoning" @@ -687,6 +719,8 @@ "summary": "A fully open model — weights, data, and training — tuned to reason.", "parameters": "32B", "context": 65536, + "downloads": 4682, + "likes": 6, "capabilities": [ "chat", "reasoning" @@ -720,6 +754,8 @@ "summary": "Efficient agentic reasoning with a one-million-token context.", "parameters": "32B", "context": 1048576, + "downloads": 30908, + "likes": 333, "capabilities": [ "chat", "reasoning", @@ -754,6 +790,8 @@ "summary": "Reads screenshots, documents, and charts, and answers questions about them.", "parameters": "8.8B", "context": 262144, + "downloads": 202398, + "likes": 98, "capabilities": [ "chat", "vision" @@ -788,6 +826,8 @@ "summary": "A pocket-sized vision model for image and video understanding.", "parameters": "752M", "context": 262144, + "downloads": 20476, + "likes": 82, "capabilities": [ "chat", "vision" @@ -820,8 +860,10 @@ "name": "GLM-OCR", "publisher": "Z.ai", "summary": "Turns complex documents into text. Small enough to keep loaded.", - "parameters": "1.1B", + "parameters": "891M", "context": 131072, + "downloads": 38418, + "likes": 115, "capabilities": [ "vision" ], @@ -830,7 +872,7 @@ { "runtime": "ollama", "model": "glm-ocr", - "size_bytes": 2200000000, + "size_bytes": 2700000000, "quantization": "F16" }, { @@ -855,6 +897,8 @@ "summary": "Token-efficient OCR, for putting scanned pages into a pipeline.", "parameters": "3.3B", "context": 8192, + "downloads": 7449, + "likes": 38, "capabilities": [ "vision" ], @@ -886,8 +930,10 @@ "name": "Qwen3.5 0.8B", "publisher": "Qwen", "summary": "Under a gigabyte, and still holds a conversation.", - "parameters": "873M", + "parameters": "752M", "context": 262144, + "downloads": 242759, + "likes": 240, "capabilities": [ "chat", "vision" @@ -897,7 +943,7 @@ { "runtime": "ollama", "model": "qwen3.5:0.8b", - "size_bytes": 1000000000, + "size_bytes": 1300000000, "quantization": "Q8_0" }, { @@ -920,8 +966,10 @@ "name": "Qwen3.5 2B", "publisher": "Qwen", "summary": "The smallest Qwen3.5 that still does tool calling well.", - "parameters": "2.3B", + "parameters": "1.9B", "context": 262144, + "downloads": 46528, + "likes": 28, "capabilities": [ "chat", "tools", @@ -932,7 +980,7 @@ { "runtime": "ollama", "model": "qwen3.5:2b", - "size_bytes": 2700000000, + "size_bytes": 3100000000, "quantization": "Q8_0" }, { @@ -957,6 +1005,8 @@ "summary": "An enterprise-ready small model with a 128K context.", "parameters": "3.4B", "context": 131072, + "downloads": 7068, + "likes": 14, "capabilities": [ "chat", "tools" @@ -990,6 +1040,8 @@ "summary": "Turns text into vectors for search. Small enough to run beside a chat model.", "parameters": "596M", "context": 32768, + "downloads": 154024, + "likes": 584, "capabilities": [ "embeddings" ], @@ -1022,6 +1074,8 @@ "summary": "A 300M embedding model, quantization-aware trained.", "parameters": "308M", "context": 2048, + "downloads": 377967, + "likes": 52, "capabilities": [ "embeddings" ], @@ -1054,6 +1108,8 @@ "summary": "Multilingual embeddings for search across many languages at once.", "parameters": "567M", "context": 8192, + "downloads": 52324, + "likes": 58, "capabilities": [ "embeddings" ], @@ -1086,6 +1142,8 @@ "summary": "The larger open-weight OpenAI model, for workstations with real memory.", "parameters": "117B", "context": 131072, + "downloads": 168072, + "likes": 79, "capabilities": [ "chat", "tools", @@ -1120,6 +1178,8 @@ "summary": "The full Qwen3.5. Needs a workstation, and rewards one.", "parameters": "125B", "context": 262144, + "downloads": 51706, + "likes": 299, "capabilities": [ "chat", "tools", @@ -1156,6 +1216,8 @@ "summary": "Meta's largest open model, with a one-million-token context.", "parameters": "816B", "context": 1048576, + "downloads": 1244, + "likes": 4, "capabilities": [ "chat", "tools", @@ -1177,6 +1239,8 @@ "summary": "Z.ai's flagship, strong at agentic coding, with a one-million-token context.", "parameters": "753B", "context": 1048576, + "downloads": 1639, + "likes": 1, "capabilities": [ "chat", "tools", @@ -1201,6 +1265,8 @@ "summary": "DeepSeek's flagship, with sparse attention for long inputs.", "parameters": "685B", "context": 163840, + "downloads": 493, + "likes": 7, "capabilities": [ "chat", "tools", @@ -1225,6 +1291,8 @@ "summary": "Qwen's largest coding model, for agentic work across a repository.", "parameters": "480B", "context": 262144, + "downloads": 801, + "likes": 19, "capabilities": [ "chat", "code", @@ -1255,6 +1323,8 @@ "summary": "The largest Qwen3.5, for chat, research, and code.", "parameters": "397B", "context": 262144, + "downloads": 404, + "likes": 11, "capabilities": [ "chat", "tools", @@ -1279,6 +1349,8 @@ "summary": "Z.ai's previous flagship, strong at coding and tool use, at half the size of GLM-5.3.", "parameters": "358B", "context": 202752, + "downloads": 696, + "likes": 5, "capabilities": [ "chat", "tools", @@ -1303,6 +1375,8 @@ "summary": "A compact mixture-of-experts model built for coding and agent work.", "parameters": "229B", "context": 196608, + "downloads": 322, + "likes": 2, "capabilities": [ "chat", "tools", @@ -1327,6 +1401,8 @@ "summary": "A lighter GLM that two smaller Macs can share.", "parameters": "110B", "context": 131072, + "downloads": 1118, + "likes": 30, "capabilities": [ "chat", "tools",