danielrosehill commited on
Commit
ff094cf
·
verified ·
1 Parent(s): 42477ca

Static tier cost visualiser

Browse files
Files changed (2) hide show
  1. README.md +15 -4
  2. index.html +242 -17
README.md CHANGED
@@ -1,10 +1,21 @@
1
  ---
2
  title: OR Batch Inference Costs
3
- emoji: 🏆
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: static
7
  pinned: false
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
1
  ---
2
  title: OR Batch Inference Costs
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: static
7
  pinned: false
8
+ license: cc-by-4.0
9
+ short_description: OpenRouter batch model costs by price tier
10
+ datasets:
11
+ - danielrosehill/OR-Batch-Inference-Models
12
  ---
13
 
14
+ # OpenRouter batch inference costs by tier
15
+
16
+ A static page that reads [danielrosehill/OR-Batch-Inference-Models](https://huggingface.co/datasets/danielrosehill/OR-Batch-Inference-Models)
17
+ in the browser and prices a job you set (input and output tokens) on every OpenRouter `:batch` model. Models are grouped into
18
+ Cheap / Mid / Expensive tiers by batch output price (rubric: [OpenRouter-Batch-Notes, entry 07](https://github.com/danielrosehill/OpenRouter-Batch-Notes/blob/main/notebook/07-price-tiers.md)).
19
+
20
+ The page lists `data/*.csv` in the dataset through the Hub API, so a new snapshot CSV shows up in the snapshot picker without changing the Space.
21
+ Source: `hf/space/` in the GitHub repo.
index.html CHANGED
@@ -1,19 +1,244 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>OpenRouter batch inference costs by tier</title>
7
+ <script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
8
+ <style>
9
+ .viz-root {
10
+ color-scheme: light;
11
+ --surface-1: #fcfcfb; --surface-2: #f3f2ef; --border: #dedcd6;
12
+ --text-primary: #0b0b0b; --text-secondary: #52514e; --text-muted: #7a7974;
13
+ --grid: #ebe9e4;
14
+ --tier-1: #86b6ef; --tier-2: #2a78d6; --tier-3: #104281;
15
+ --accent: #2a78d6;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ .viz-root {
19
+ color-scheme: dark;
20
+ --surface-1: #1a1a19; --surface-2: #242423; --border: #3a3a38;
21
+ --text-primary: #f0efec; --text-secondary: #c3c2b7; --text-muted: #8f8e88;
22
+ --grid: #2c2c2a;
23
+ --tier-1: #184f95; --tier-2: #3987e5; --tier-3: #9ec5f4;
24
+ --accent: #3987e5;
25
+ }
26
+ }
27
+ * { box-sizing: border-box; }
28
+ body { margin: 0; }
29
+ .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; }
30
+ .wrap { max-width: 1180px; margin: 0 auto; }
31
+ h1 { font-size: 22px; margin: 0 0 4px; }
32
+ h2 { font-size: 16px; margin: 28px 0 4px; }
33
+ p.sub, .note { color: var(--text-secondary); margin: 0 0 12px; }
34
+ .note { font-size: 12px; color: var(--text-muted); }
35
+ a { color: var(--accent); }
36
+ .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; }
37
+ .ctl { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-secondary); }
38
+ .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; }
39
+ .ctl select { width: 160px; }
40
+ .btns { display: flex; gap: 6px; }
41
+ 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; }
42
+ button[aria-pressed=true] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
43
+ .checks { display: flex; gap: 10px; font-size: 13px; color: var(--text-primary); }
44
+ .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
45
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; }
46
+ .tile { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; background: var(--surface-1); }
47
+ .tile .k { font-size: 12px; color: var(--text-secondary); }
48
+ .tile .v { font-size: 20px; font-weight: 600; margin: 2px 0; font-variant-numeric: tabular-nums; }
49
+ .tile .d { font-size: 12px; color: var(--text-muted); }
50
+ .chart { width: 100%; }
51
+ table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
52
+ th, td { padding: 5px 8px; border-bottom: 1px solid var(--grid); text-align: right; white-space: nowrap; }
53
+ th { color: var(--text-secondary); font-weight: 600; cursor: pointer; position: sticky; top: 0; background: var(--surface-1); }
54
+ th:nth-child(-n+3), td:nth-child(-n+3) { text-align: left; }
55
+ .tablebox { max-height: 520px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; }
56
+ code { font-size: 12px; }
57
+ .flag { font-weight: 700; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <div class="viz-root"><div class="wrap">
62
+ <h1>OpenRouter batch inference costs by tier</h1>
63
+ <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>
64
+
65
+ <div class="controls" role="group" aria-label="Job and filters">
66
+ <label class="ctl">Snapshot<select id="snap"></select></label>
67
+ <label class="ctl">Input tokens (millions)<input id="tin" type="number" min="0" step="0.1" value="4.5"></label>
68
+ <label class="ctl">Output tokens (millions)<input id="tout" type="number" min="0" step="0.1" value="0.5"></label>
69
+ <div class="ctl">Job shape
70
+ <div class="btns" id="presets">
71
+ <button data-i="4.5" data-o="0.5" aria-pressed="true">Labelling 9:1</button>
72
+ <button data-i="1" data-o="1" aria-pressed="false">Balanced 1:1</button>
73
+ <button data-i="0.5" data-o="2" aria-pressed="false">Generation 1:4</button>
74
+ </div>
75
+ </div>
76
+ <div class="ctl">Tiers
77
+ <div class="checks" id="tierchecks"></div>
78
+ </div>
79
+ <label class="ctl">Provider<select id="prov"><option value="">All</option></select></label>
80
+ </div>
81
+
82
+ <div class="tiles" id="tiles"></div>
83
+ <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>
84
+
85
+ <h2>Cost of the job, per model</h2>
86
+ <p class="note">Batch prices with reasoning tokens excluded. Log scale. Hover a bar for prices.</p>
87
+ <div id="bars" class="chart"></div>
88
+
89
+ <h2>Input vs output price</h2>
90
+ <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>
91
+ <div id="scatter" class="chart"></div>
92
+
93
+ <h2>Table</h2>
94
+ <div class="tablebox"><table id="tbl"><thead></thead><tbody></tbody></table></div>
95
+ <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>
96
+ </div></div>
97
+
98
+ <script>
99
+ const DS = "danielrosehill/OR-Batch-Inference-Models";
100
+ const TIERS = ["Cheap", "Mid", "Expensive"];
101
+ 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"]];
102
+ const css = n => getComputedStyle(document.querySelector(".viz-root")).getPropertyValue(n).trim();
103
+ const tierColor = t => css("--tier-" + (TIERS.indexOf(t) + 1));
104
+ const fmt$ = v => v >= 100 ? "$" + v.toFixed(0) : v >= 10 ? "$" + v.toFixed(1) : "$" + v.toFixed(2);
105
+ const state = { rows: [], tiers: new Set(TIERS), prov: "", sort: ["job", 1] };
106
+
107
+ function parseCSV(text) {
108
+ const out = []; let row = [], f = "", q = false;
109
+ for (let i = 0; i < text.length; i++) {
110
+ const c = text[i];
111
+ if (q) { if (c === '"') { if (text[i+1] === '"') { f += '"'; i++; } else q = false; } else f += c; }
112
+ else if (c === '"') q = true;
113
+ else if (c === ",") { row.push(f); f = ""; }
114
+ else if (c === "\n") { row.push(f); out.push(row); row = []; f = ""; }
115
+ else if (c !== "\r") f += c;
116
+ }
117
+ if (f || row.length) { row.push(f); out.push(row); }
118
+ const [h, ...rs] = out;
119
+ return rs.filter(r => r.length === h.length).map(r => Object.fromEntries(h.map((k, i) => [k, r[i]])));
120
+ }
121
+
122
+ async function listSnapshots() {
123
+ try {
124
+ const r = await fetch(`https://huggingface.co/api/datasets/${DS}/tree/main/data`);
125
+ const files = (await r.json()).map(f => f.path).filter(p => p.endsWith(".csv")).sort().reverse();
126
+ if (files.length) return files;
127
+ } catch (e) {}
128
+ return ["data/batch_models_2026-10-31.csv"];
129
+ }
130
+
131
+ async function load(path) {
132
+ const t = await (await fetch(`https://huggingface.co/datasets/${DS}/resolve/main/${path}`)).text();
133
+ state.rows = parseCSV(t).map(r => ({ ...r,
134
+ bin: +r.batch_input_usd_per_m, bout: +r.batch_output_usd_per_m,
135
+ sin: r.sync_input_usd_per_m === "" ? null : +r.sync_input_usd_per_m,
136
+ sout: r.sync_output_usd_per_m === "" ? null : +r.sync_output_usd_per_m,
137
+ mismatch: r.input_mismatch === "True", provs: r.batch_providers.split(";") }));
138
+ const provs = [...new Set(state.rows.flatMap(r => r.provs))].sort();
139
+ const sel = document.getElementById("prov");
140
+ sel.innerHTML = '<option value="">All</option>' + provs.map(p => `<option>${p}</option>`).join("");
141
+ sel.value = provs.includes(state.prov) ? state.prov : "";
142
+ state.prov = sel.value;
143
+ render();
144
+ }
145
+
146
+ function visible() {
147
+ const ti = +document.getElementById("tin").value || 0, to = +document.getElementById("tout").value || 0;
148
+ return state.rows
149
+ .filter(r => state.tiers.has(r.tier) && (!state.prov || r.provs.includes(state.prov)))
150
+ .map(r => ({ ...r, job: r.bin * ti + r.bout * to }));
151
+ }
152
+
153
+ const baseLayout = () => ({
154
+ paper_bgcolor: css("--surface-1"), plot_bgcolor: css("--surface-1"),
155
+ font: { family: "system-ui, sans-serif", size: 12, color: css("--text-secondary") },
156
+ hoverlabel: { bgcolor: css("--surface-2"), bordercolor: css("--border"), font: { color: css("--text-primary") } },
157
+ legend: { orientation: "h", y: 1.06, x: 0, font: { color: css("--text-primary") } },
158
+ });
159
+ const axis = extra => ({ gridcolor: css("--grid"), zeroline: false, linecolor: css("--border"), tickfont: { color: css("--text-secondary") }, ...extra });
160
+
161
+ function render() {
162
+ const rows = visible();
163
+ const job = `${document.getElementById("tin").value}M in + ${document.getElementById("tout").value}M out`;
164
+
165
+ // tiles
166
+ document.getElementById("tiles").innerHTML = BANDS.map(([b, t, lbl]) => {
167
+ const g = rows.filter(r => r.sub_band === b);
168
+ if (!g.length) return "";
169
+ const lo = g.reduce((a, r) => r.job < a.job ? r : a), hi = g.reduce((a, r) => r.job > a.job ? r : a);
170
+ return `<div class="tile"><div class="k"><span class="swatch" style="background:${tierColor(t)}"></span>${t} ${b} · ${lbl} · ${g.length} models</div>
171
+ <div class="v">${fmt$(lo.job)} – ${fmt$(hi.job)}</div>
172
+ <div class="d">${lo.model.split("/")[1]} → ${hi.model.split("/")[1]}</div></div>`;
173
+ }).join("");
174
+
175
+ // bars, sorted by job cost, one trace per tier for the legend
176
+ const sorted = [...rows].sort((a, b) => a.job - b.job);
177
+ const order = sorted.map(r => r.model);
178
+ const barTraces = TIERS.filter(t => state.tiers.has(t)).map(t => {
179
+ const g = sorted.filter(r => r.tier === t);
180
+ return { type: "bar", orientation: "h", name: t, x: g.map(r => r.job), y: g.map(r => r.model),
181
+ marker: { color: tierColor(t), line: { width: 0 }, cornerradius: 4 },
182
+ text: g.map(r => fmt$(r.job)), textposition: "outside", cliponaxis: false,
183
+ textfont: { color: css("--text-secondary"), size: 11 },
184
+ 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}`]),
185
+ 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]}<extra></extra>" };
186
+ });
187
+ Plotly.react("bars", barTraces, { ...baseLayout(), height: Math.max(260, 18 * sorted.length + 90), bargap: 0.25,
188
+ margin: { l: 230, r: 60, t: 40, b: 40 },
189
+ xaxis: axis({ type: "log", title: { text: "US$ for the job (log)", font: { size: 12 } }, tickprefix: "$" }),
190
+ yaxis: axis({ categoryorder: "array", categoryarray: order.slice().reverse(), automargin: true, gridcolor: "rgba(0,0,0,0)", tickfont: { color: css("--text-primary"), size: 11 } }),
191
+ }, { displayModeBar: false, responsive: true });
192
+
193
+ // scatter
194
+ const xs = rows.map(r => r.bin), xmin = Math.min(...xs, 0.02) / 1.3, xmax = Math.max(...xs, 15) * 1.3;
195
+ const scatter = TIERS.filter(t => state.tiers.has(t)).map(t => {
196
+ const g = rows.filter(r => r.tier === t);
197
+ return { type: "scatter", mode: "markers", name: t, x: g.map(r => r.bin), y: g.map(r => r.bout),
198
+ marker: { size: g.map(r => r.mismatch ? 13 : 10), color: tierColor(t),
199
+ line: { color: g.map(r => r.mismatch ? css("--text-primary") : css("--surface-1")), width: g.map(r => r.mismatch ? 2.5 : 2) } },
200
+ text: g.map(r => r.model),
201
+ customdata: g.map(r => [r.out_in_ratio, r.sub_band, r.input_band, fmt$(r.job)]),
202
+ 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>" };
203
+ });
204
+ const hline = y => ({ type: "line", xref: "paper", x0: 0, x1: 1, y0: y, y1: y, line: { color: css("--text-muted"), width: 1, dash: "dash" } });
205
+ Plotly.react("scatter", scatter, { ...baseLayout(), height: 520, margin: { l: 70, r: 20, t: 40, b: 55 },
206
+ xaxis: axis({ type: "log", title: { text: "Batch input, US$ per M tokens (log)", font: { size: 12 } }, tickprefix: "$", range: [Math.log10(xmin), Math.log10(xmax)] }),
207
+ yaxis: axis({ type: "log", title: { text: "Batch output, US$ per M tokens (log)", font: { size: 12 } }, tickprefix: "$" }),
208
+ 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" } }],
209
+ annotations: [
210
+ { xref: "paper", x: 1, y: Math.log10(3), text: "$3", showarrow: false, xanchor: "right", yanchor: "bottom", font: { color: css("--text-muted"), size: 11 } },
211
+ { xref: "paper", x: 1, y: Math.log10(20), text: "$20", showarrow: false, xanchor: "right", yanchor: "bottom", font: { color: css("--text-muted"), size: 11 } }],
212
+ }, { displayModeBar: false, responsive: true });
213
+
214
+ // table
215
+ 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"],["batch_providers","Providers"]];
216
+ const [k, dir] = state.sort;
217
+ 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; });
218
+ const thead = document.querySelector("#tbl thead");
219
+ thead.innerHTML = "<tr>" + cols.map(([c, l]) => `<th data-k="${c}" scope="col">${l}${c === k ? (dir > 0 ? " ▲" : " ▼") : ""}</th>`).join("") + "</tr>";
220
+ thead.querySelectorAll("th").forEach(th => th.onclick = () => { state.sort = [th.dataset.k, state.sort[0] === th.dataset.k ? -state.sort[1] : 1]; render(); });
221
+ 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 style="text-align:left">${r.batch_providers.replace(/;/g, ", ")}</td></tr>`).join("");
222
+ }
223
+
224
+ function setup() {
225
+ 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("");
226
+ document.querySelectorAll("#tierchecks input").forEach(cb => cb.onchange = () => { cb.checked ? state.tiers.add(cb.value) : state.tiers.delete(cb.value); render(); });
227
+ const pbtn = document.querySelectorAll("#presets button");
228
+ 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(); });
229
+ ["tin", "tout"].forEach(id => document.getElementById(id).oninput = () => { pbtn.forEach(x => x.setAttribute("aria-pressed", false)); render(); });
230
+ document.getElementById("prov").onchange = e => { state.prov = e.target.value; render(); };
231
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { setup(); render(); });
232
+ }
233
+
234
+ (async () => {
235
+ setup();
236
+ const snaps = await listSnapshots();
237
+ const s = document.getElementById("snap");
238
+ s.innerHTML = snaps.map(p => `<option value="${p}">${(p.match(/\d{4}-\d{2}-\d{2}/) || [p])[0]}</option>`).join("");
239
+ s.onchange = () => load(s.value);
240
+ load(snaps[0]);
241
+ })();
242
+ </script>
243
+ </body>
244
  </html>