Download index.html from danielrosehill/OR-Batch-Inference-Costs: direct link, hf CLI and curl.
- Browser
- Download file 17.2 kB
-
https://huggingface.co/spaces/danielrosehill/OR-Batch-Inference-Costs/resolve/main/index.html
- Command line
-
hf download hf://spaces/danielrosehill/OR-Batch-Inference-Costs/index.html
-
curl -L -o index.html https://huggingface.co/spaces/danielrosehill/OR-Batch-Inference-Costs/resolve/main/index.html
17.2 kB
| <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> | |