Canonical Model Intelligence — Design
Status: passes 1–3 and 4a are implemented (accessors + artifact in
packages/model-pricing, the rankings sync + nightly workflow, the
find_model rank term, the enriched /api/models/recommended/*
endpoints). The picker work in pass 4 and the first real rankings
artifact (which lands via the nightly sync PR) are still open.
The problem
NodeTool lists fal-ai/kling-video/v3/pro (FAL) and kling/v3-pro (kie) as
two unrelated models. They are two routes to one model. The picker shows the
same model many times, nothing orders a list of 80 video models by quality,
and find_model ranks candidates only by the static RECOMMENDED_MODELS
list, provider hints, and locality.
An earlier draft of this design proposed a new packages/model-catalog
package with a hand-written CanonicalModel registry, a hand-written
MODEL_ALIASES table, a ModelMetadata.ranking field on the wire model
types, and per-route latency/reliability scores. This document keeps that
draft’s one load-bearing idea — canonical model ≠ provider route — and
discards most of its machinery, because the repo already ships the hard half.
What already exists
The GenSpend pricing sync (scripts/sync-genspend-pricing.mjs,
scripts/genspend/) already solves canonicalization for the media models,
and its output already ships in every release:
packages/model-pricing/src/generated/genspend-pricing.jsonis keyed<provider_id>:<model_id>and every entry carriesmodel_slug— a canonical model id. Today that is 276 priced provider-native ids resolving to 104 canonical slugs, 51 of which span more than one provider (seedance-2→ atlascloud + kie,seedream-5-pro→ atlascloud + kie, …).scripts/genspend/normalize.mjsis the naming bridge: one comparison key per model (FLUX.2 [pro]≡flux-2-pro), vendor-prefix stripping, task suffix stripping, and the guards that keep it honest (a family name that matches too many ids is dropped as ambiguous, a capability flag that refutes a task blocks the match).scripts/genspend/match.mjsresolves an upstream model to NodeTool ids through a five-tier trust ladder (alias>variant>provider-id>receipt>catalog), andscripts/genspend/aliases.jsonis the hand-pin escape hatch: pin a match the comparison cannot see, or block a wrong one, without weakening the rules for everything else.- The nightly
GenSpend Pricing Syncworkflow regenerates the artifact and opens a PR — numbers that gate spend get reviewed, not auto-merged. getGenspendPrice(provider, modelId)is how the web UI and the runner read route facts (price) today: a synchronous lookup on a shipped snapshot, no network, no key.
So “introduce canonical model ≠ provider route” is not a new abstraction to
build. It is a field to promote. The design below is: make model_slug
first-class, and ship quality rankings as a second generated artifact in the
same shape, through the same pipeline pattern, read through the same kind of
lookup.
Design
1. The canonical id is GenSpend’s model_slug
No new CanonicalModel registry, no new MODEL_ALIASES table in runtime
code. The slug vocabulary, the slug→provider-id resolution, its trust
ladder, and its hand-pin file already exist and are already reviewed
nightly. A second canonicalization mechanism would drift from the first;
the pricing sync’s is the one that gets exercised and corrected because
wrong prices get noticed.
Two consequences, both acceptable:
- A model GenSpend does not track has no canonical id. It stays exactly what it is today: listed, runnable, unranked, ungrouped. Canonical grouping is an enhancement, and “unmapped” is the designed fallback — an incorrect merge is worse than an unmapped model.
- The vocabulary is a third party’s. A renamed slug shows up as a diff in the nightly sync PR, where a maintainer sees it. Shipped artifacts freeze the slugs they were generated with, so a rename can never break an installed app.
Scope: media models (image, video, TTS, music) — the modalities where routes
multiply and where FAL/kie/atlascloud/replicate overlap. Language models are
out of scope for v1: their ids are already near-canonical per provider, and
find_model already handles them adequately.
2. Rankings are a second generated artifact in packages/model-pricing
scripts/sync-model-rankings.mjs # new, sibling of sync-genspend-pricing.mjs
scripts/rankings/aliases.json # hand-pins: AA/Arena name → canonical slug, or null to block
↓ (Artificial Analysis data API; Arena leaderboard dataset later)
packages/model-pricing/src/generated/model-rankings.json
packages/model-pricing/src/model-rankings.ts # typed accessor module
Not a new packages/model-catalog package. model-pricing is already the
package whose job is “externally sourced model facts, shipped as a generated
snapshot, read by web and runner” — rankings are the second column of the
same table. If the package outgrows its name, renaming it to
@nodetool-ai/model-catalog is a mechanical follow-up PR; creating a 57th
package for two JSON files is not the place to start.
The artifact mirrors the pricing catalog’s shape — keyed by
<provider_id>:<model_id>, expanded at sync time, so runtime does zero
matching:
{
"schemaVersion": 1,
"source": "artificialanalysis.ai",
"generatedAt": "2026-08-19T...",
"models": {
"fal_ai:fal-ai/kling-video/v3/pro": {
"canonical": "kling-3-pro",
"name": "Kling 3 Pro",
"creator": "Kuaishou",
"tasks": {
"text_to_video": { "score": 1123, "normalized": 0.94, "rank": 2, "of": 41 },
"image_to_video": { "score": 1101, "normalized": 0.91, "rank": 3, "of": 38 }
}
},
"kie:kling/v3-pro": { "canonical": "kling-3-pro", ... }
}
}
canonicalgroups routes; every route to one model carries identicaltasks— quality is a property of the model, never of the route.taskskeys are NodeTool’s ownsupportedTasksvocabulary (text_to_image,image_to_video, …), which is what the AA media API’s per-task leaderboards map onto. No single overall score per model — a model mediocre at text-to-image can lead at editing.scoreis the source-native number (arena-style rating),normalizedits 0–1 position within that task’s leaderboard,rank/offor display. No inventedconfidence, no stored badges — “best quality” / “best value” are derived at render time from rank + price, not persisted.
How the sync matches AA/Arena names to canonical slugs: the GenSpend catalog
snapshot the pricing sync already downloads carries each model’s slug,
name, shortName, and aliases[]. The rankings sync indexes those with
the existing modelKeys() from scripts/genspend/normalize.mjs and looks
each AA model up by the same exact-key comparison — no fuzzy matching, no
prefix matching. Anything unmatched is reported, never guessed, and
scripts/rankings/aliases.json pins or blocks the stragglers by hand,
exactly as scripts/genspend/aliases.json does for prices. Expansion from
slug to provider:model_id keys reuses the resolution the pricing artifact
already records — a slug’s routes are simply the pricing entries that carry
it, plus alias pins.
Operationally it copies the pricing sync verbatim: nightly workflow, opens a
PR only when something moved, sync:model-rankings:check fails CI when the
artifact and a fresh measurement disagree, no API key in the shipped app, no
network call at startup. NodeTool is local-first; a ranking that evaporates
when a leaderboard site is down is worse than a week-stale snapshot.
3. Reading it: a lookup, not a field on the wire types
The earlier draft added metadata?: { ranking?: … } to ImageModel /
VideoModel and enriched inside loadImageModels(). Rejected:
- Those are runtime routing objects that cross the wire in every model-list
response, from ~30 providers. Enriching in the loaders touches every
provider path to serve a concern only three consumers have, and couples
packages/runtimeto ranking data it never uses to execute anything. - The repo already has the right pattern in production: prices never sat on
the model objects either.
web/src/utils/modelUnitPricing.tsand the runner both callgetGenspendPrice(provider, id)where they need it.
So the accessor module is the whole runtime API:
// packages/model-pricing/src/model-rankings.ts
getModelRank(provider, modelId): ModelRank | null // one route's entry
getCanonicalId(provider, modelId): string | null // grouping key
routesFor(canonicalId): RouteEntry[] // all provider routes to one model
rankedForTask(task): RankedCanonicalModel[] // leaderboard, canonical models
ImageModel, VideoModel, and friends do not change. Nothing in
packages/runtime’s provider layer changes.
4. Consumers
find_model / nodetool.models.pick (packages/agents/src/capabilities/models.ts).
Today’s score is additive: recommended +100, provider hint +200, model hint
+250, local +150. Add one bounded term — normalized × 80 for the requested
task — below the explicit-preference bonuses, so a user hint still outranks
a leaderboard, and attach rank/of/canonical to the returned
candidates. Two routes to one canonical model collapse to the better-priced
one in the top results, with the alternates listed under it. The agent
answer the draft asked for (“Kling 3 Pro, FAL, rank 2 of 41, $0.18/s — also
via kie at $0.14/s”) falls out of routesFor + getGenspendPrice, both
already-shipped lookups. This is worth more than the picker UI: model choice
by agents is currently blind past the recommended set.
/api/models/recommended* (packages/websocket/src/models-api.ts).
The task-specific endpoints (/recommended/video/text-to-video, …) merge
ranked canonical models into what they return today.
RECOMMENDED_MODELS stays, demoted to what it really is: hand-pinned
overrides that always surface first (and the only mechanism for modalities
and providers the rankings cannot see — local Ollama models, ASR). It stops
being the sole ordering.
Model picker (web, then mobile). Group entries by canonical: one row
per model, routes as sub-entries with per-route price from the pricing
catalog. Sort ranked models by task rank above the unranked remainder
(alphabetical, as today) under an “All models” divider — the full list stays
complete; NodeTool’s selling point of offering everything is untouched.
Badges (Best quality, Best value, Fastest where duration data exists)
are computed in the picker from rank + price. Web reads the accessor module
directly, as it reads prices today; mobile reads the enriched
/api/models/recommended* responses.
5. What is deliberately not built
- No
ModelRouteruntime object and no route scores. The draft’s per-route latency (42s vs 58s) and reliability (99.6% vs 98.9%) numbers have no data source — NodeTool has no fleet telemetry and should not invent numbers. Route facts NodeTool can actually stand behind are price (shipped) and, later, the user’s own prediction ledger (nodetool costs) for observed local latency. When such a source exists, it becomes another lookup keyedprovider:model_id; the shape is ready for it. - No weighted
recommendation = 0.65·quality + 0.20·value + …formula. A stored composite hides its inputs and invites tuning debates. Sort by task quality; show price beside it; let explicit user hints dominate infind_model. The one place that blends (the find_model score term) is a single bounded addend in existing code, not a scoring service. - No runtime fuzzy matching, ever. All matching happens at sync time,
is exact-key, and lands in a reviewed PR. Unmatched models are reported
in the sync output and fixed in
aliases.jsonby hand. - No automatic cross-provider failover. Routing a run to a different
provider than the one saved on the node is a spend decision the user did
not make.
routesFormakes an offered alternative possible (in the picker, in an agent’s answer); nothing switches silently.
Failure posture
Everything fails toward today’s behavior. A missing or empty
model-rankings.json means: no grouping, no rank term in find_model, the
picker sorts as it does now. A model absent from the artifact is unranked,
never hidden and never down-ranked below where it sits today (the rank term
only adds). The sync fails closed: an AA response that does not parse, or a
leaderboard whose task cannot be mapped, drops that task from the artifact
and says so in the PR body, rather than shipping a number nobody can trace.
Per the repo’s check discipline: the sync’s parity check must be proven able to fail (perturb one score, watch it go red) before it gates anything, and the unmatched-models report must assert it found the fixtures it plants.
Rollout
- Accessor + artifact plumbing —
model-rankings.ts, an empty-but-valid generated file,rankedForTask/routesFor/getCanonicalIdwith unit tests against a fixture artifact. No behavior change anywhere. - Sync —
sync-model-rankings.mjsagainst the Artificial Analysis data API (its media leaderboards map cleanly ontosupportedTasks), reusingscripts/genspend/normalize.mjs; nightly workflow +:check; first real artifact lands by PR. Arena’s dataset joins later as a second signal — averaging sources is a sync-time concern and changes nothing downstream. - Agent surface — the
find_modelrank term and canonical/route fields in its answer; extend thefind_modelcases in the eval suites to pin that a ranked model outranks an unranked one and that hints still win. - API + pickers — enrich
/api/models/recommended*; group and sort the web picker; mobile follows on the API alone.
Each pass ships alone and is useful alone; pass 1+2 already give agents and the API the data even before any UI moves.