tutorial
Watch the OpenRouter model catalog change, with no key and no dependencies
Verified against OpenRouter /api/v1/models as served 2026-08-28, total_count 398 on . Re-verify every 30 days.
https://openrouter.ai/api/v1/models returns every model the router sells,
unauthenticated, in one page, with links.next null. On the day this was
written that was 398 rows in 657,669 bytes. Four short files — no packages,
no key, no account — turn it into a change log for the part of the market you
care about, and the rest of this page is about the four fields in it that will
mislead you if you read them the obvious way.
1. Take a snapshot
// snapshot.mjs
import { mkdir, writeFile } from 'node:fs/promises';
const ENDPOINT = 'https://openrouter.ai/api/v1/models';
const res = await fetch(ENDPOINT, { headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`${ENDPOINT} -> HTTP ${res.status}`);
const body = await res.json();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
await mkdir('snapshots', { recursive: true });
const file = `snapshots/${stamp}.json`;
await writeFile(file, JSON.stringify(body));
console.log(`HTTP ${res.status} ${res.headers.get('content-type')}`);
console.log(`${body.data.length} rows -> ${file}`);
$ node snapshot.mjs
HTTP 200 application/json
398 rows -> snapshots/2026-08-28T20-26-39-358Z.json
Keep the raw body, not a tidied version of it. Every field you throw away now is a change you cannot detect later, and the whole file is under a megabyte.
2. Read it once, carefully
// report.mjs
import { readdir, readFile } from 'node:fs/promises';
const perM = (s) => (s == null ? null : Number(s) * 1e6);
const files = (await readdir('snapshots')).filter((f) => f.endsWith('.json')).sort();
const latest = files.at(-1);
const rows = JSON.parse(await readFile(`snapshots/${latest}`, 'utf8')).data;
const priced = rows.filter((r) => perM(r.pricing?.prompt) > 0);
const free = rows.filter((r) => Number(r.pricing?.prompt) === 0);
const expiring = rows.filter((r) => r.expiration_date);
const cached = rows.filter((r) => Number(r.pricing?.input_cache_read) > 0);
const disagree = rows.filter(
(r) => r.top_provider?.context_length != null
&& r.top_provider.context_length !== r.context_length,
);
console.log(`snapshot ${latest}`);
console.log(`rows ${rows.length}`);
console.log(`priced input ${priced.length}`);
console.log(`zero-priced input ${free.length}`);
console.log(`carry expiration_date ${expiring.length}`);
console.log(`price cached input reads ${cached.length}`);
console.log(`context_length disagrees ${disagree.length}`);
const bar = 200_000;
const wide = priced
.filter((r) => r.context_length >= bar)
.sort((a, b) => perM(a.pricing.prompt) - perM(b.pricing.prompt));
console.log(`\ncheapest input at >= ${bar / 1000}k context (${wide.length} rows qualify)`);
console.log(' in/Mtok out/Mtok context id');
for (const r of wide.slice(0, 8)) {
console.log([
perM(r.pricing.prompt).toFixed(3).padStart(11),
perM(r.pricing.completion).toFixed(3).padStart(9),
String(r.context_length).padStart(10),
r.id,
].join(' '));
}
$ node report.mjs
snapshot 2026-08-28T20-40-24-918Z.json
rows 398
priced input 372
zero-priced input 21
carry expiration_date 8
price cached input reads 236
context_length disagrees 40
cheapest input at >= 200k context (248 rows qualify)
in/Mtok out/Mtok context id
0.021 0.063 262144 inclusionai/ling-3.0-flash
0.025 0.100 262144 nex-agi/nex-n2-mini
0.030 0.120 524288 upstage/solar-pro4
0.030 0.100 1310720 ~deepseek/deepseek-v4-flash-latest
0.030 0.130 1000000 qwen/qwen3.7-flash
0.048 0.193 262144 qwen/qwen3-30b-a3b-instruct-2507
0.050 0.100 1310720 deepseek/deepseek-v4-flash-0731
0.050 0.200 262144 nvidia/nemotron-3-nano-30b-a3b
372 + 21 = 393, not 398. Five rows are neither priced nor free. Two rows
in the table have a tilde in front of their id. And 248 of the 372 priced
rows clear a 200k-token bar that was the frontier two years ago. Each of those is a field
worth pulling apart before you trust a sort.
3. Pull the misleading fields apart
// fields.mjs
import { readdir, readFile } from 'node:fs/promises';
const files = (await readdir('snapshots')).filter((f) => f.endsWith('.json')).sort();
const rows = JSON.parse(await readFile(`snapshots/${files.at(-1)}`, 'utf8')).data;
const price = (r) => Number(r.pricing?.prompt);
console.log('-- rows whose pricing.prompt is neither > 0 nor exactly 0');
for (const r of rows.filter((r) => !(price(r) > 0) && price(r) !== 0)) {
console.log(` ${r.id.padEnd(24)} ${JSON.stringify(r.pricing.prompt)} ctx ${r.context_length}`);
}
console.log('\n-- rows with an expiration_date');
for (const r of rows.filter((r) => r.expiration_date)
.sort((a, b) => a.expiration_date.localeCompare(b.expiration_date))) {
console.log(` ${r.expiration_date} ${r.id}`);
}
console.log('\n-- id suffix census');
const suffix = new Map();
for (const r of rows) {
const s = r.id.includes(':') ? ':' + r.id.split(':')[1] : '(none)';
suffix.set(s, (suffix.get(s) ?? 0) + 1);
}
console.log(' ' + [...suffix].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}=${v}`).join(' '));
console.log('\n-- zero-priced rows without a :free suffix');
for (const r of rows.filter((r) => price(r) === 0 && !r.id.endsWith(':free'))) console.log(` ${r.id}`);
console.log('\n-- biggest context_length / top_provider.context_length gaps');
const gap = (r) => r.context_length - r.top_provider.context_length;
for (const r of rows
.filter((r) => r.top_provider?.context_length != null && gap(r) > 0)
.sort((a, b) => gap(b) - gap(a))
.slice(0, 5)) {
console.log(` ${r.id.padEnd(34)} listed ${String(r.context_length).padStart(8)} top provider ${String(r.top_provider.context_length).padStart(7)}`);
}
console.log('\n-- ids beginning with ~');
console.log(' ' + rows.filter((r) => r.id.startsWith('~')).map((r) => r.id).sort().join('\n '));
console.log('\n-- biggest prompt : input_cache_read price ratios');
for (const x of rows
.filter((r) => Number(r.pricing?.input_cache_read) > 0)
.map((r) => ({ id: r.id, ratio: Number(r.pricing.prompt) / Number(r.pricing.input_cache_read) }))
.filter((x) => Number.isFinite(x.ratio))
.sort((a, b) => b.ratio - a.ratio)
.slice(0, 4)) {
console.log(` ${x.id.padEnd(38)} ${x.ratio.toFixed(1)}x`);
}
$ node fields.mjs
-- rows whose pricing.prompt is neither > 0 nor exactly 0
openrouter/auto-beta "-1" ctx 2000000
openrouter/fusion "-1" ctx 1000000
openrouter/pareto-code "-1" ctx 2000000
openrouter/bodybuilder "-1" ctx 128000
openrouter/auto "-1" ctx 2000000
-- rows with an expiration_date
2026-08-31 moonshotai/kimi-k2.5
2026-09-30 dots-studio/dots-3-note-preview:free
2026-12-31 z-ai/glm-4.5v
2026-12-31 z-ai/glm-4.5
2098-12-31 z-ai/glm-5.3-flash
2098-12-31 z-ai/glm-5.3
2098-12-31 z-ai/glm-5v-turbo
2098-12-31 z-ai/glm-5-turbo
-- id suffix census
(none)=339 :batch=41 :free=18
-- zero-priced rows without a :free suffix
google/lyria-3-pro-preview
google/lyria-3-clip-preview
openrouter/free
-- biggest context_length / top_provider.context_length gaps
meta-llama/llama-4-scout listed 1310720 top provider 131072
~z-ai/glm-latest listed 1310720 top provider 262144
thedrummer/unslopnemo-12b listed 1024000 top provider 32768
anthropic/claude-sonnet-4 listed 1000000 top provider 200000
qwen/qwen3.8-2.4t-a95b listed 1048576 top provider 262144
-- ids beginning with ~
~anthropic/claude-fable-latest
~anthropic/claude-haiku-latest
~anthropic/claude-opus-latest
~anthropic/claude-sonnet-latest
~deepseek/deepseek-v4-flash-latest
~google/gemini-flash-latest
~google/gemini-pro-latest
~moonshotai/kimi-latest
~openai/gpt-latest
~openai/gpt-mini-latest
~x-ai/grok-latest
~z-ai/glm-latest
-- biggest prompt : input_cache_read price ratios
xiaomi/mimo-v2.5-pro 120.8x
xiaomi/mimo-v2.5 50.0x
meituan/longcat-2.0 50.0x
meta/muse-spark-1.2-contributor 50.0x
-1 is a sentinel, and it is contagious. The five unpriced rows are
routers — rows that mean "pick a model for me", whose price is not knowable
in advance. -1 says that clearly and is a catastrophic number to compute
with. Sort ascending on Number(pricing.prompt) and your five cheapest
models each cost minus a million per million tokens; take a minimum and it is
-1; average anything and the average is wrong. Three of the five also claim
the two largest advertised windows on the whole list, so "widest context"
returns routers too. The fix is a filter, not a special case:
Number(r.pricing.prompt) >= 0 drops exactly the rows whose price is
undefined by construction.
expiration_date is a retirement notice almost nobody reads. The first
line of that list is a model three days from being switched off. Its own page
on the site was rendered in a browser the same day and searched: none of
2026-08-31, expir, retir, deprecat or sunset appears anywhere in
the 14,936 characters of visible text, and the date does not appear in the
/models listing page at all. It is in the page's data payload and in this
endpoint, and nowhere a reader will meet it.
Note the second half of that list: four rows use 2098-12-31 as a "never"
sentinel. An alert built on "has an expiration date" fires on those forever;
the useful predicate is a date inside the next year or two, not the presence
of the field.
:free is a naming convention, not a price. The suffix census counts 18
ids ending :free, but 21 rows are priced at zero — google/lyria-3-pro-preview,
google/lyria-3-clip-preview and openrouter/free carry no such suffix.
Filter on pricing.prompt, never on the id.
context_length is a ceiling; top_provider.context_length is what you
get. They disagree on 40 rows, sometimes by an order of magnitude — a
"million-token model" whose serving provider stops at 131072 is not a
million-token model for your request.
A tilde means the id is a moving target. Twelve ids begin with ~, all
of them floating aliases. They are genuinely useful, and they break the
meaning of a diff: on the day the model behind ~openai/gpt-latest is
swapped, the row's identity does not change, so a differ reports an ordinary
price or context edit rather than a substitution. Resolve a floating id to a
concrete one before you record any benchmark result against it.
One field is worth reading for the opposite reason.
pricing.input_cache_read is priced on 236 of the 398 rows and the discount
is not uniform: xiaomi/mimo-v2.5-pro reads cached input at 120.8x less
than fresh input, meituan/longcat-2.0 at 50.0x. If your workload has a
long stable prefix, that ratio moves your bill more than the headline price
does, and it is on no comparison chart.
4. Diff two snapshots
// changes.mjs
import { readdir, readFile } from 'node:fs/promises';
const DIR = process.argv[2] ?? 'snapshots';
const WATCH = {
'price in': (r) => r.pricing?.prompt,
'price out': (r) => r.pricing?.completion,
context: (r) => r.context_length,
expires: (r) => r.expiration_date ?? '-',
};
const load = async (f) =>
new Map(JSON.parse(await readFile(`${DIR}/${f}`, 'utf8')).data.map((r) => [r.id, r]));
const files = (await readdir(DIR)).filter((f) => f.endsWith('.json')).sort();
if (files.length < 2) {
console.log(`${DIR}: need two snapshots; run snapshot.mjs again later`);
process.exit(0);
}
const [oldFile, newFile] = [files.at(-2), files.at(-1)];
const [was, now] = [await load(oldFile), await load(newFile)];
const lines = [];
for (const id of now.keys()) if (!was.has(id)) lines.push(`arrived ${id}`);
for (const id of was.keys()) if (!now.has(id)) lines.push(`gone ${id}`);
for (const [id, row] of now) {
const before = was.get(id);
if (!before) continue;
for (const [label, read] of Object.entries(WATCH)) {
const a = String(read(before));
const b = String(read(row));
if (a !== b) lines.push(`changed ${id} ${label}: ${a} -> ${b}`);
}
}
console.log(`${oldFile}\n${newFile}`);
for (const l of lines.sort()) console.log(l);
console.log(`${lines.length} change${lines.length === 1 ? '' : 's'}`);
Comparing by id — not by name, not by array position — is the only part of
this that is load-bearing. name is a display string, and the array order is
not stable.
Two snapshots fourteen minutes apart:
$ node changes.mjs
2026-08-28T20-26-39-358Z.json
2026-08-28T20-40-24-918Z.json
0 changes
Zero is the correct and usual answer. Run it on a schedule and most runs will print exactly this.
5. Which is why you have to poison it on purpose
A watcher whose normal output is "nothing happened" is indistinguishable from a watcher that has quietly stopped working. Make a doctored copy of a real snapshot and check that the differ still sees the three kinds of change.
// canary.mjs
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
const files = (await readdir('snapshots')).filter((f) => f.endsWith('.json')).sort();
const body = JSON.parse(await readFile(`snapshots/${files.at(-1)}`, 'utf8'));
await mkdir('canary', { recursive: true });
await writeFile('canary/1-real.json', JSON.stringify(body));
let rows = body.data.map((r) => ({ ...r }));
const target = rows.find((r) => r.id === 'openai/gpt-4');
target.pricing = { ...target.pricing, prompt: '0.000015' };
rows.find((r) => r.id === 'openai/gpt-3.5-turbo').context_length = 4096;
const dropped = rows.find((r) => r.id === 'openai/gpt-4-turbo');
rows = rows.filter((r) => r !== dropped);
rows.push({ ...rows[0], id: 'fictional/never-shipped-1' });
await writeFile('canary/2-doctored.json', JSON.stringify({ ...body, data: rows }));
console.log(`doctored copy written; dropped ${dropped.id}`);
$ node canary.mjs && node changes.mjs canary
doctored copy written; dropped openai/gpt-4-turbo
1-real.json
2-doctored.json
arrived fictional/never-shipped-1
changed openai/gpt-3.5-turbo context: 16385 -> 4096
changed openai/gpt-4 price in: 0.00003 -> 0.000015
gone openai/gpt-4-turbo
4 changes
Four for four. Now "0 changes" means something.
What was executed
Every command and every output above was run on 2026-08-28 against the live
endpoint, on Windows 10 with Node v24.13.0. Nothing is mocked and no output
is reconstructed: the two snapshots are real fetches fourteen minutes apart,
and the canary diff is the real differ run against a doctored copy of a real
snapshot. The model-page check was a real page load in headless Chromium
151.0.7922.34.
Not executed: any run spanning more than one day, so no output here shows a real arrival, departure or price move. That is what the canary is for.
What will break this first
- Row counts and prices move daily; every number above is dated 2026-08-28 and is meant to be re-derived, not quoted.
-1is undocumented behavior as far as the response itself goes. It could becomenull, and the>= 0filter would then need!= nullbeside it.links.nextis null today. If the catalog is ever paginated, a script that readsdataonce will silently watch a prefix of the market.- The
~prefix and the:free/:batchsuffixes are conventions, not guarantees, and conventions are exactly what changes without a changelog.