danielrosehill's picture
Add endpoint stability filter and column
b85b5fe verified
Raw History Blame Contribute Delete
17.2 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenRouter batch inference costs by tier</title>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<style>
.viz-root {
color-scheme: light;
--surface-1: #fcfcfb; --surface-2: #f3f2ef; --border: #dedcd6;
--text-primary: #0b0b0b; --text-secondary: #52514e; --text-muted: #7a7974;
--grid: #ebe9e4;
--tier-1: #86b6ef; --tier-2: #2a78d6; --tier-3: #104281;
--accent: #2a78d6;
}
@media (prefers-color-scheme: dark) {
.viz-root {
color-scheme: dark;
--surface-1: #1a1a19; --surface-2: #242423; --border: #3a3a38;
--text-primary: #f0efec; --text-secondary: #c3c2b7; --text-muted: #8f8e88;
--grid: #2c2c2a;
--tier-1: #184f95; --tier-2: #3987e5; --tier-3: #9ec5f4;
--accent: #3987e5;
}
}
* { box-sizing: border-box; }
body { margin: 0; }
.viz-root { background: var(--surface-1); color: var(--text-primary); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; min-height: 100vh; padding: 24px; }
.wrap { max-width: 1180px; margin: 0 auto; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 28px 0 4px; }
p.sub, .note { color: var(--text-secondary); margin: 0 0 12px; }
.note { font-size: 12px; color: var(--text-muted); }
a { color: var(--accent); }
.controls { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: end; padding: 14px 16px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; margin: 16px 0; }
.ctl { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-secondary); }
.ctl input[type=number], .ctl select { width: 110px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-1); color: var(--text-primary); font: inherit; font-size: 14px; }
.ctl select { width: 160px; }
.btns { display: flex; gap: 6px; }
button { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-1); color: var(--text-primary); font: inherit; font-size: 13px; cursor: pointer; }
button[aria-pressed=true] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.checks { display: flex; gap: 10px; font-size: 13px; color: var(--text-primary); }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; }
.tile { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; background: var(--surface-1); }
.tile .k { font-size: 12px; color: var(--text-secondary); }
.tile .v { font-size: 20px; font-weight: 600; margin: 2px 0; font-variant-numeric: tabular-nums; }
.tile .d { font-size: 12px; color: var(--text-muted); }
.chart { width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
th, td { padding: 5px 8px; border-bottom: 1px solid var(--grid); text-align: right; white-space: nowrap; }
th { color: var(--text-secondary); font-weight: 600; cursor: pointer; position: sticky; top: 0; background: var(--surface-1); }
th:nth-child(-n+3), td:nth-child(-n+3) { text-align: left; }
.tablebox { max-height: 520px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; }
code { font-size: 12px; }
.flag { font-weight: 700; }
</style>
</head>
<body>
<div class="viz-root"><div class="wrap">
<h1>OpenRouter batch inference costs by tier</h1>
<p class="sub">Every model with a <code>:batch</code> id on OpenRouter, priced for a job you set below. Tiers are set by batch output price: <b>Cheap</b> under $3 per million output tokens, <b>Mid</b> $3–20, <b>Expensive</b> $20 and up. Data: <a id="dslink" href="https://huggingface.co/datasets/danielrosehill/OR-Batch-Inference-Models">OR-Batch-Inference-Models</a>.</p>
<div class="controls" role="group" aria-label="Job and filters">
<label class="ctl">Snapshot<select id="snap"></select></label>
<label class="ctl">Input tokens (millions)<input id="tin" type="number" min="0" step="0.1" value="4.5"></label>
<label class="ctl">Output tokens (millions)<input id="tout" type="number" min="0" step="0.1" value="0.5"></label>
<div class="ctl">Job shape
<div class="btns" id="presets">
<button data-i="4.5" data-o="0.5" aria-pressed="true">Labelling 9:1</button>
<button data-i="1" data-o="1" aria-pressed="false">Balanced 1:1</button>
<button data-i="0.5" data-o="2" aria-pressed="false">Generation 1:4</button>
</div>
</div>
<div class="ctl">Tiers
<div class="checks" id="tierchecks"></div>
</div>
<label class="ctl">Provider<select id="prov"><option value="">All</option></select></label>
<label class="ctl">Endpoint<select id="stab"><option value="">All</option><option value="stable">Stable versions only</option><option value="unstable">Aliases only</option></select></label>
</div>
<div class="tiles" id="tiles"></div>
<p class="note">Each tile shows the cheapest and dearest model in that sub-band for the job above. The models are grouped by output price, so on input-heavy jobs the cost ranges of neighbouring bands can overlap.</p>
<h2>Cost of the job, per model</h2>
<p class="note">Batch prices with reasoning tokens excluded. Log scale. Hover a bar for prices.</p>
<div id="bars" class="chart"></div>
<h2>Input vs output price</h2>
<p class="note">US$ per million tokens, log–log. Dashed lines mark the tier boundaries at $3 and $20 output. Models above the dotted line charge more than 5× input for output. Ringed points have input priced in a different tier from their output.</p>
<div id="scatter" class="chart"></div>
<h2>Table</h2>
<p class="note"><b>Stable</b> = the id always resolves to one model version. OpenRouter's <code>-latest</code> aliases have no batch ids. GPT-4o, GPT-4o mini and GPT-4 Turbo are marked No: their slugs carry no date, so OpenAI can repoint them. Hover the cell for the reason. Columns are documented in the dataset's data dictionary.</p>
<div class="tablebox"><table id="tbl"><thead></thead><tbody></tbody></table></div>
<p class="note">Prices change often: re-check on <a href="https://openrouter.ai/models">openrouter.ai</a> before relying on them. Rubric and method: <a href="https://github.com/danielrosehill/OpenRouter-Batch-Notes/blob/main/notebook/07-price-tiers.md">OpenRouter-Batch-Notes, entry 07</a>.</p>
</div></div>
<script>
const DS = "danielrosehill/OR-Batch-Inference-Models";
const TIERS = ["Cheap", "Mid", "Expensive"];
const BANDS = [["1a","Cheap","≤ $1 out"],["1b","Cheap","$1–3 out"],["2a","Mid","$3–8 out"],["2b","Mid","$8–20 out"],["3a","Expensive","$20–50 out"],["3b","Expensive","≥ $50 out"]];
const css = n => getComputedStyle(document.querySelector(".viz-root")).getPropertyValue(n).trim();
const tierColor = t => css("--tier-" + (TIERS.indexOf(t) + 1));
const fmt$ = v => v >= 100 ? "$" + v.toFixed(0) : v >= 10 ? "$" + v.toFixed(1) : "$" + v.toFixed(2);
const state = { rows: [], tiers: new Set(TIERS), prov: "", stab: "", sort: ["job", 1] };
function parseCSV(text) {
const out = []; let row = [], f = "", q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) { if (c === '"') { if (text[i+1] === '"') { f += '"'; i++; } else q = false; } else f += c; }
else if (c === '"') q = true;
else if (c === ",") { row.push(f); f = ""; }
else if (c === "\n") { row.push(f); out.push(row); row = []; f = ""; }
else if (c !== "\r") f += c;
}
if (f || row.length) { row.push(f); out.push(row); }
const [h, ...rs] = out;
return rs.filter(r => r.length === h.length).map(r => Object.fromEntries(h.map((k, i) => [k, r[i]])));
}
async function listSnapshots() {
try {
const r = await fetch(`https://huggingface.co/api/datasets/${DS}/tree/main/data`);
const files = (await r.json()).map(f => f.path).filter(p => p.endsWith(".csv")).sort().reverse();
if (files.length) return files;
} catch (e) {}
return ["data/batch_models_2026-10-31.csv"];
}
async function load(path) {
const t = await (await fetch(`https://huggingface.co/datasets/${DS}/resolve/main/${path}`)).text();
state.rows = parseCSV(t).map(r => ({ ...r,
bin: +r.batch_input_usd_per_m, bout: +r.batch_output_usd_per_m,
sin: r.sync_input_usd_per_m === "" ? null : +r.sync_input_usd_per_m,
sout: r.sync_output_usd_per_m === "" ? null : +r.sync_output_usd_per_m,
mismatch: r.input_mismatch === "True", stable: r.stable_endpoint !== "False", provs: r.batch_providers.split(";") }));
const provs = [...new Set(state.rows.flatMap(r => r.provs))].sort();
const sel = document.getElementById("prov");
sel.innerHTML = '<option value="">All</option>' + provs.map(p => `<option>${p}</option>`).join("");
sel.value = provs.includes(state.prov) ? state.prov : "";
state.prov = sel.value;
render();
}
function visible() {
const ti = +document.getElementById("tin").value || 0, to = +document.getElementById("tout").value || 0;
return state.rows
.filter(r => state.tiers.has(r.tier) && (!state.prov || r.provs.includes(state.prov)) && (!state.stab || (state.stab === "stable") === r.stable))
.map(r => ({ ...r, job: r.bin * ti + r.bout * to }));
}
const baseLayout = () => ({
paper_bgcolor: css("--surface-1"), plot_bgcolor: css("--surface-1"),
font: { family: "system-ui, sans-serif", size: 12, color: css("--text-secondary") },
hoverlabel: { bgcolor: css("--surface-2"), bordercolor: css("--border"), font: { color: css("--text-primary") } },
legend: { orientation: "h", y: 1.06, x: 0, font: { color: css("--text-primary") } },
});
const axis = extra => ({ gridcolor: css("--grid"), zeroline: false, linecolor: css("--border"), tickfont: { color: css("--text-secondary") }, ...extra });
function render() {
const rows = visible();
const job = `${document.getElementById("tin").value}M in + ${document.getElementById("tout").value}M out`;
// tiles
document.getElementById("tiles").innerHTML = BANDS.map(([b, t, lbl]) => {
const g = rows.filter(r => r.sub_band === b);
if (!g.length) return "";
const lo = g.reduce((a, r) => r.job < a.job ? r : a), hi = g.reduce((a, r) => r.job > a.job ? r : a);
return `<div class="tile"><div class="k"><span class="swatch" style="background:${tierColor(t)}"></span>${t} ${b} · ${lbl} · ${g.length} models</div>
<div class="v">${fmt$(lo.job)} – ${fmt$(hi.job)}</div>
<div class="d">${lo.model.split("/")[1]} → ${hi.model.split("/")[1]}</div></div>`;
}).join("");
// bars, sorted by job cost, one trace per tier for the legend
const sorted = [...rows].sort((a, b) => a.job - b.job);
const order = sorted.map(r => r.model);
const barTraces = TIERS.filter(t => state.tiers.has(t)).map(t => {
const g = sorted.filter(r => r.tier === t);
return { type: "bar", orientation: "h", name: t, x: g.map(r => r.job), y: g.map(r => r.model),
marker: { color: tierColor(t), line: { width: 0 }, cornerradius: 4 },
text: g.map(r => fmt$(r.job)), textposition: "outside", cliponaxis: false,
textfont: { color: css("--text-secondary"), size: 11 },
customdata: g.map(r => [r.bin, r.bout, r.sub_band, r.batch_providers.replace(/;/g, ", "), r.sin == null ? "n/a" : `${r.sin} / ${r.sout}`, r.stable ? "stable version" : "alias, can be repointed"]),
hovertemplate: "<b>%{y}</b><br>Job (" + job + "): %{x:$,.2f}<br>Batch in / out: $%{customdata[0]} / $%{customdata[1]} per M<br>Sync in / out: %{customdata[4]}<br>Band %{customdata[2]} · %{customdata[3]}<br>Endpoint: %{customdata[5]}<extra></extra>" };
});
Plotly.react("bars", barTraces, { ...baseLayout(), height: Math.max(260, 18 * sorted.length + 90), bargap: 0.25,
margin: { l: 230, r: 60, t: 40, b: 40 },
xaxis: axis({ type: "log", title: { text: "US$ for the job (log)", font: { size: 12 } }, tickprefix: "$" }),
yaxis: axis({ categoryorder: "array", categoryarray: order.slice().reverse(), automargin: true, gridcolor: "rgba(0,0,0,0)", tickfont: { color: css("--text-primary"), size: 11 } }),
}, { displayModeBar: false, responsive: true });
// scatter
const xs = rows.map(r => r.bin), xmin = Math.min(...xs, 0.02) / 1.3, xmax = Math.max(...xs, 15) * 1.3;
const scatter = TIERS.filter(t => state.tiers.has(t)).map(t => {
const g = rows.filter(r => r.tier === t);
return { type: "scatter", mode: "markers", name: t, x: g.map(r => r.bin), y: g.map(r => r.bout),
marker: { size: g.map(r => r.mismatch ? 13 : 10), color: tierColor(t),
line: { color: g.map(r => r.mismatch ? css("--text-primary") : css("--surface-1")), width: g.map(r => r.mismatch ? 2.5 : 2) } },
text: g.map(r => r.model),
customdata: g.map(r => [r.out_in_ratio, r.sub_band, r.input_band, fmt$(r.job)]),
hovertemplate: "<b>%{text}</b><br>Batch in / out: $%{x} / $%{y} per M<br>Output ÷ input: %{customdata[0]}×<br>Band %{customdata[1]} · input band %{customdata[2]}<br>Job: %{customdata[3]}<extra></extra>" };
});
const hline = y => ({ type: "line", xref: "paper", x0: 0, x1: 1, y0: y, y1: y, line: { color: css("--text-muted"), width: 1, dash: "dash" } });
Plotly.react("scatter", scatter, { ...baseLayout(), height: 520, margin: { l: 70, r: 20, t: 40, b: 55 },
xaxis: axis({ type: "log", title: { text: "Batch input, US$ per M tokens (log)", font: { size: 12 } }, tickprefix: "$", range: [Math.log10(xmin), Math.log10(xmax)] }),
yaxis: axis({ type: "log", title: { text: "Batch output, US$ per M tokens (log)", font: { size: 12 } }, tickprefix: "$" }),
shapes: [hline(3), hline(20), { type: "line", x0: xmin, x1: xmax, y0: xmin * 5, y1: xmax * 5, line: { color: css("--text-muted"), width: 1, dash: "dot" } }],
annotations: [
{ xref: "paper", x: 1, y: Math.log10(3), text: "$3", showarrow: false, xanchor: "right", yanchor: "bottom", font: { color: css("--text-muted"), size: 11 } },
{ xref: "paper", x: 1, y: Math.log10(20), text: "$20", showarrow: false, xanchor: "right", yanchor: "bottom", font: { color: css("--text-muted"), size: 11 } }],
}, { displayModeBar: false, responsive: true });
// table
const cols = [["model","Model"],["tier","Tier"],["sub_band","Band"],["bin","Batch in"],["bout","Batch out"],["out_in_ratio","Out÷in"],["input_band","Input band"],["sin","Sync in"],["sout","Sync out"],["job","Job"],["stable","Stable"],["batch_providers","Providers"]];
const [k, dir] = state.sort;
const trs = [...rows].sort((a, b) => { const x = a[k], y = b[k]; return (typeof x === "number" || x == null ? (x ?? -1) - (y ?? -1) : String(x).localeCompare(y)) * dir; });
const thead = document.querySelector("#tbl thead");
thead.innerHTML = "<tr>" + cols.map(([c, l]) => `<th data-k="${c}" scope="col">${l}${c === k ? (dir > 0 ? " ▲" : " ▼") : ""}</th>`).join("") + "</tr>";
thead.querySelectorAll("th").forEach(th => th.onclick = () => { state.sort = [th.dataset.k, state.sort[0] === th.dataset.k ? -state.sort[1] : 1]; render(); });
document.querySelector("#tbl tbody").innerHTML = trs.map(r => `<tr><td><code>${r.model}</code></td><td><span class="swatch" style="background:${tierColor(r.tier)}"></span>${r.tier}</td><td>${r.sub_band}</td><td>${r.bin}</td><td>${r.bout}</td><td>${r.out_in_ratio}</td><td${r.mismatch ? ' class="flag"' : ""}>${r.input_band}</td><td>${r.sin ?? "n/a"}</td><td>${r.sout ?? "n/a"}</td><td>${fmt$(r.job)}</td><td title="${r.stability_basis || ""}">${r.stable ? "Yes" : "<b>No</b>"}</td><td style="text-align:left">${r.batch_providers.replace(/;/g, ", ")}</td></tr>`).join("");
}
function setup() {
document.getElementById("tierchecks").innerHTML = TIERS.map(t => `<label><input type="checkbox" value="${t}" ${state.tiers.has(t) ? "checked" : ""}> <span class="swatch" style="background:${tierColor(t)}"></span>${t}</label>`).join("");
document.querySelectorAll("#tierchecks input").forEach(cb => cb.onchange = () => { cb.checked ? state.tiers.add(cb.value) : state.tiers.delete(cb.value); render(); });
const pbtn = document.querySelectorAll("#presets button");
pbtn.forEach(b => b.onclick = () => { document.getElementById("tin").value = b.dataset.i; document.getElementById("tout").value = b.dataset.o; pbtn.forEach(x => x.setAttribute("aria-pressed", x === b)); render(); });
["tin", "tout"].forEach(id => document.getElementById(id).oninput = () => { pbtn.forEach(x => x.setAttribute("aria-pressed", false)); render(); });
document.getElementById("prov").onchange = e => { state.prov = e.target.value; render(); };
document.getElementById("stab").onchange = e => { state.stab = e.target.value; render(); };
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { setup(); render(); });
}
(async () => {
setup();
const snaps = await listSnapshots();
const s = document.getElementById("snap");
s.innerHTML = snaps.map(p => `<option value="${p}">${(p.match(/\d{4}-\d{2}-\d{2}/) || [p])[0]}</option>`).join("");
s.onchange = () => load(s.value);
load(snaps[0]);
})();
</script>
</body>
</html>