Static tier cost visualiser
Browse files- README.md +15 -4
- index.html +242 -17
README.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
| 1 |
---
|
| 2 |
title: OR Batch Inference Costs
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|