diff --git a/client/src/components/models/ModelComparison.jsx b/client/src/components/models/ModelComparison.jsx index eebbe26aeb..99687bcb06 100644 --- a/client/src/components/models/ModelComparison.jsx +++ b/client/src/components/models/ModelComparison.jsx @@ -79,6 +79,15 @@ const EFFORT_ORDER = { reasoning: EFFORT_LADDER.length + 1, }; +const AXES = { + cost: { label: 'Cost per task (USD)', short: 'cost per task', money: true, prefer: 'lower' }, + quality: { label: 'Benchmark score', short: 'index score', prefer: 'higher' }, + tokensPerSecond: { label: 'Speed (tokens/s)', short: 'speed', prefer: 'higher' }, + responseSeconds: { label: 'Response time (seconds)', short: 'response time', prefer: 'lower' }, + inputPerMillion: { label: 'Input price (USD / 1M tokens)', short: 'input price', money: true, prefer: 'lower' }, + outputPerMillion: { label: 'Output price (USD / 1M tokens)', short: 'output price', money: true, prefer: 'lower' }, +}; + const STALE_MS = 30 * 86400000; function getModelDisplayName(row) { @@ -126,10 +135,10 @@ export default function ModelComparison() { const yMinParam = params.get('yMin'); const yMaxParam = params.get('yMax'); - const parsedXMin = xMinParam !== null && xMinParam !== '' && !Number.isNaN(Number(xMinParam)) ? Number(xMinParam) : null; - const parsedXMax = xMaxParam !== null && xMaxParam !== '' && !Number.isNaN(Number(xMaxParam)) ? Number(xMaxParam) : null; - const parsedYMin = yMinParam !== null && yMinParam !== '' && !Number.isNaN(Number(yMinParam)) ? Number(yMinParam) : null; - const parsedYMax = yMaxParam !== null && yMaxParam !== '' && !Number.isNaN(Number(yMaxParam)) ? Number(yMaxParam) : null; + const parsedXMin = xMinParam !== null && xMinParam !== '' && Number.isFinite(Number(xMinParam)) ? Number(xMinParam) : null; + const parsedXMax = xMaxParam !== null && xMaxParam !== '' && Number.isFinite(Number(xMaxParam)) ? Number(xMaxParam) : null; + const parsedYMin = yMinParam !== null && yMinParam !== '' && Number.isFinite(Number(yMinParam)) ? Number(yMinParam) : null; + const parsedYMax = yMaxParam !== null && yMaxParam !== '' && Number.isFinite(Number(yMaxParam)) ? Number(yMaxParam) : null; const isZoomed = parsedXMin !== null || parsedXMax !== null || parsedYMin !== null || parsedYMax !== null; @@ -162,7 +171,11 @@ export default function ModelComparison() { }, [catalog, showEstimates]); // Models the user's own providers can dispatch. Everything else in the index // is available behind "All models" but is not what the page opens on. - const availableSet = useMemo(() => new Set(catalog?.availableModels || []), [catalog]); + const availableSet = useMemo(() => new Set([ + ...(catalog?.availableModels || []), + // Keep executable endpoint IDs as well as normalized public model references. + ...(catalog?.inventory || []).flatMap(provider => provider.models.flatMap(({ model }) => model.startsWith('opencode/') ? [model, model.slice('opencode/'.length)] : [model])), + ]), [catalog]); useEffect(() => { let active = true; @@ -307,14 +320,17 @@ export default function ModelComparison() { const benchmarks = [...new Set(catalog.observations.map(row => row.benchmark))].sort(); const benchmark = benchmarks.includes(params.get('benchmark')) ? params.get('benchmark') - : benchmarks.at(-1); + : [...new Set(catalog.observations.filter(row => row.quality).map(row => row.benchmark))].sort().at(-1) || benchmarks.at(-1); const mode = params.get('cost') === 'scenario' ? 'scenario' : 'benchmark'; const showLines = params.get('lines') !== '0'; const lineStyle = params.get('lineStyle') || 'dotted'; const showLabels = params.get('labels') === '1' || !params.has('labels'); + const xAxis = Object.hasOwn(AXES, params.get('xAxis')) ? params.get('xAxis') : 'cost'; + const yAxis = Object.hasOwn(AXES, params.get('yAxis')) ? params.get('yAxis') : 'quality'; + const changeAxis = (key, value) => changeParams({ [key]: value, xMin: null, xMax: null, yMin: null, yMax: null, scale: 'linear' }); // Cost spans four orders of magnitude across the catalog, so a linear axis // stacks every affordable model on the y-axis. Log is the readable default. - const scale = params.get('scale') === 'linear' ? 'linear' : 'log'; + const scale = params.get('scale') === 'log' || (!params.has('scale') && xAxis === 'cost') ? 'log' : 'linear'; const showAllModels = params.get('allModels') === '1' || availableSet.size === 0; const stretch = Math.min(4, Math.max(1, parseFloat(params.get('stretch')) || 1)); const chartHeight = Math.min(1000, Math.max(380, parseInt(params.get('height'), 10) || 480)); @@ -354,7 +370,7 @@ export default function ModelComparison() { }; const visible = scoped.filter( row => - row.benchmark === benchmark && + (row.benchmark === benchmark || row.benchmark === 'Unbenchmarked (pricing only)') && !hidden.provider.has(row.provider) && !hidden.model.has(row.model) && !hidden.effort.has(row.effort) @@ -377,16 +393,18 @@ export default function ModelComparison() { return { ...row, displayName, - x: cost, - y: row.quality?.value, + cost, + x: xAxis === 'cost' ? cost : row[xAxis]?.value, + y: yAxis === 'cost' ? cost : row[yAxis]?.value, costEstimated: mode === 'benchmark' && row.costEstimated === true, + chartCostEstimated: mode === 'benchmark' && (xAxis === 'cost' || yAxis === 'cost') && row.costEstimated === true, label: `${row.model} (${row.effort})${row.responseSeconds ? ` · ${row.responseSeconds.value}s` : ''}`, }; }); const plotted = rows.filter(row => Number.isFinite(row.x) && Number.isFinite(row.y) && (scale !== 'log' || row.x > 0)); - const xs = plotted.map(r => r.x).filter(x => Number.isFinite(x) && x > 0); + const xs = plotted.map(r => r.x).filter(x => Number.isFinite(x) && (scale !== 'log' || x > 0)); const ys = plotted.map(r => r.y).filter(y => Number.isFinite(y)); const dataBounds = { xMin: xs.length ? Math.min(...xs) : 0.01, @@ -486,7 +504,7 @@ export default function ModelComparison() { const handleFitVisible = () => { if (!plotted.length) return; - const xs = plotted.map(r => r.x).filter(x => Number.isFinite(x) && x > 0); + const xs = plotted.map(r => r.x).filter(x => Number.isFinite(x) && (scale !== 'log' || x > 0)); const ys = plotted.map(r => r.y).filter(y => Number.isFinite(y)); if (!xs.length || !ys.length) return; @@ -502,7 +520,7 @@ export default function ModelComparison() { fitXMax = Number((maxX * 1.1).toPrecision(2)); } else { fitXMin = Math.max(0, Number((minX * 0.9).toFixed(3))); - fitXMax = Number((maxX * 1.05).toFixed(3)); + fitXMax = Math.max(fitXMin + 0.001, Number((maxX * 1.05).toFixed(3))); } const fitYMin = Math.max(0, Math.floor(minY - 2)); const fitYMax = Math.ceil(maxY + 2); @@ -543,7 +561,7 @@ export default function ModelComparison() { let estimatedCount = 0; const multiEffortModelCounts = new Map(); for (const row of plotted) { - if (row.costEstimated) estimatedCount += 1; + if (row.chartCostEstimated) estimatedCount += 1; multiEffortModelCounts.set(row.model, (multiEffortModelCounts.get(row.model) || 0) + 1); } const reasoningCurveModels = [...multiEffortModelCounts.entries()] @@ -673,6 +691,18 @@ export default function ModelComparison() { +
+ {[["xAxis", "X axis", xAxis], ["yAxis", "Y axis", yAxis]].map(([key, label, value]) => ( + + ))} +
+ {/* Primary Chart Controls Bar */}