From 5a533e366f60165efc3c0b1c6dcd872122845f8a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:43:56 -0500 Subject: [PATCH] feat(stats): add model comparison pages --- .../stats/app/src/routes/[lab]/[model].tsx | 59 ++ packages/stats/app/src/routes/[lab]/index.tsx | 36 ++ .../stats/app/src/routes/compare-cards.tsx | 88 +++ .../stats/app/src/routes/compare-selector.tsx | 83 +++ .../[secondLab]/[secondModel].tsx | 608 ++++++++++++++++++ .../stats/app/src/routes/compare/index.tsx | 148 +++++ packages/stats/app/src/routes/index.css | 255 +++++++- packages/stats/app/src/routes/index.tsx | 38 +- packages/stats/app/src/routes/stats-shell.tsx | 5 +- packages/stats/core/src/domain/home.ts | 58 +- 10 files changed, 1373 insertions(+), 5 deletions(-) create mode 100644 packages/stats/app/src/routes/compare-cards.tsx create mode 100644 packages/stats/app/src/routes/compare-selector.tsx create mode 100644 packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx create mode 100644 packages/stats/app/src/routes/compare/index.tsx diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index adbbf95c5c..ddd8ff1e2e 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -33,6 +33,12 @@ import { import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" +import { + ComparisonCardsSection, + modelRefFromCatalog, + uniqueComparisonPairs, + type ComparisonModelRef, +} from "../compare-cards" import { applyThemePreference, Footer, @@ -189,6 +195,11 @@ export default function StatsModel() { + @@ -197,6 +208,7 @@ export default function StatsModel() { themePreference={themePreference()} onThemePreferenceChange={updateThemePreference} links={modelFooterLinks()} + bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }} /> @@ -1172,6 +1184,53 @@ function ModelEmptyState(props: { title: string; description: string; compact?: ) } +function modelComparisonPairs( + catalog: ModelCatalog | undefined, + catalogEntry: ModelCatalogEntry | null, + data: StatsModelData | null, +) { + const current = modelComparisonRef(catalogEntry, data) + if (!current) return [] + const peerPairs = (data?.peers ?? []) + .filter((peer) => peer.model !== data?.model) + .slice(0, 3) + .map((peer) => ({ + first: current, + second: { + name: peer.model, + lab: peer.provider, + slug: peer.slug, + labName: peer.author, + metric: `#${peer.rank} / ${formatTokens(peer.tokens)}`, + }, + detail: "Usage peer", + })) + const catalogPairs = (catalogEntry && catalog ? catalog.labs.find((lab) => lab.id === catalogEntry.lab)?.models ?? [] : []) + .filter((model) => model.id !== catalogEntry?.id) + .slice(0, 3) + .map((model) => ({ + first: current, + second: modelRefFromCatalog(model), + detail: "Same lab pair", + })) + return uniqueComparisonPairs([...peerPairs, ...catalogPairs]) +} + +function modelComparisonRef( + catalogEntry: ModelCatalogEntry | null, + data: StatsModelData | null, +): ComparisonModelRef | undefined { + if (catalogEntry) return modelRefFromCatalog(catalogEntry) + if (!data) return undefined + return { + name: data.model, + lab: data.provider, + slug: data.slug, + labName: data.author, + metric: `#${data.rank}`, + } +} + function getProviderIconId(author: string) { if (author === "MiniMax") return "minimax" if (author === "Moonshot") return "moonshotai" diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index c3147b1c57..17b0783823 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -28,6 +28,11 @@ import { import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" +import { + ComparisonCardsSection, + modelRefFromCatalog, + uniqueComparisonPairs, +} from "../compare-cards" import { applyThemePreference, Footer, @@ -149,6 +154,11 @@ export default function StatsLab() { labs={catalog()?.labs ?? []} market={homeStats()?.market["2M"] ?? []} /> + )} @@ -158,6 +168,7 @@ export default function StatsLab() { themePreference={themePreference()} onThemePreferenceChange={updateThemePreference} links={labFooterLinks()} + bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }} /> @@ -732,6 +743,31 @@ function LabEmptyState(props: { title: string; description: string }) { ) } +function labComparisonPairs(lab: ModelCatalogLab, usage: LabUsageModelEntry[]) { + const usageRefs = usage.slice(0, 4).map((model) => ({ + name: model.model, + lab: model.provider, + slug: model.slug, + labName: model.author, + metric: formatTokens(model.tokens), + })) + const refs = usageRefs.length > 1 ? usageRefs : lab.models.slice(0, 4).map(modelRefFromCatalog) + return uniqueComparisonPairs( + ( + [ + [0, 1, "Most-used lab pair"], + [0, 2, "Lab alternative"], + [1, 2, "Adjacent lab pair"], + [2, 3, "Same lab pair"], + ] as const + ).flatMap(([firstIndex, secondIndex, detail]) => { + const first = refs[firstIndex] + const second = refs[secondIndex] + return first && second ? [{ first, second, detail }] : [] + }), + ) +} + type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number } function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] { diff --git a/packages/stats/app/src/routes/compare-cards.tsx b/packages/stats/app/src/routes/compare-cards.tsx new file mode 100644 index 0000000000..10f2f7677a --- /dev/null +++ b/packages/stats/app/src/routes/compare-cards.tsx @@ -0,0 +1,88 @@ +import { For, Show } from "solid-js" +import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog" + +export type ComparisonModelRef = { + name: string + lab: string + slug: string + labName?: string + metric?: string +} + +export type ComparisonPair = { + first: ComparisonModelRef + second: ComparisonModelRef + detail: string +} + +export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef { + return { + name: entry.name, + lab: entry.lab, + slug: entry.slug, + labName: formatCatalogLabName(entry.lab), + } +} + +export function comparisonHref(first: ComparisonModelRef, second: ComparisonModelRef) { + return `${import.meta.env.BASE_URL}compare/${catalogSlug(first.lab)}/${catalogSlug(first.slug)}/${catalogSlug( + second.lab, + )}/${catalogSlug(second.slug)}` +} + +export function uniqueComparisonPairs(pairs: ComparisonPair[]) { + return pairs.reduce<{ keys: Set; pairs: ComparisonPair[] }>( + (result, pair) => { + const key = [modelKey(pair.first), modelKey(pair.second)].toSorted().join("|") + if (result.keys.has(key) || modelKey(pair.first) === modelKey(pair.second)) return result + result.keys.add(key) + result.pairs.push(pair) + return result + }, + { keys: new Set(), pairs: [] }, + ).pairs +} + +export function ComparisonCardsSection(props: { + pairs: ComparisonPair[] + title?: string + description?: string + compact?: boolean +}) { + return ( + 0}> +
+

+ {props.title ?? "Model Comparisons"}.{" "} + {props.description ?? "Compare usage, cost, limits, and features."} +

+ +
+
+ ) +} + +function modelKey(model: ComparisonModelRef) { + return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}` +} diff --git a/packages/stats/app/src/routes/compare-selector.tsx b/packages/stats/app/src/routes/compare-selector.tsx new file mode 100644 index 0000000000..42c1349ea2 --- /dev/null +++ b/packages/stats/app/src/routes/compare-selector.tsx @@ -0,0 +1,83 @@ +import { createEffect, createMemo, createSignal, For } from "solid-js" +import { comparisonHref, modelRefFromCatalog } from "./compare-cards" +import type { ModelCatalogEntry } from "./model-catalog" + +export function ComparisonSelector(props: { + models: ModelCatalogEntry[] + firstId?: string + secondId?: string + label?: string +}) { + const [firstId, setFirstId] = createSignal(props.firstId ?? props.models[0]?.id ?? "") + const [secondId, setSecondId] = createSignal(differentModelId(props.models, firstId(), props.secondId)) + const modelById = createMemo(() => new Map(props.models.map((model) => [model.id, model]))) + const href = createMemo(() => { + const first = modelById().get(firstId()) + const second = modelById().get(secondId()) + if (!first || !second || first.id === second.id) return undefined + return comparisonHref(modelRefFromCatalog(first), modelRefFromCatalog(second)) + }) + + createEffect(() => { + if (firstId() && modelById().has(firstId())) return + const next = props.firstId && modelById().has(props.firstId) ? props.firstId : props.models[0]?.id + if (next) setFirstId(next) + }) + + createEffect(() => { + if (secondId() && secondId() !== firstId() && modelById().has(secondId())) return + setSecondId(differentModelId(props.models, firstId(), props.secondId)) + }) + + return ( +
{ + event.preventDefault() + const url = href() + if (!url || typeof window === "undefined") return + window.location.href = url + }} + > + + + +
+ ) +} + +function differentModelId(models: ModelCatalogEntry[], firstId: string, preferredId: string | undefined) { + if (preferredId && preferredId !== firstId && models.some((model) => model.id === preferredId)) return preferredId + return models.find((model) => model.id !== firstId)?.id ?? "" +} diff --git a/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx b/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx new file mode 100644 index 0000000000..6455e0f148 --- /dev/null +++ b/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx @@ -0,0 +1,608 @@ +import "../../../../index.css" +import { Link, Meta, Title } from "@solidjs/meta" +import { + getStatsModelComparisonData, + type StatsModelComparisonEntry, +} from "@opencode-ai/stats-core/domain/home" +import { runtime } from "@opencode-ai/stats-core/runtime" +import { createAsync, query, useParams } from "@solidjs/router" +import { createMemo, createSignal, For, onMount, Show } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import { + ComparisonCardsSection, + modelRefFromCatalog, + uniqueComparisonPairs, + type ComparisonModelRef, + type ComparisonPair, +} from "../../../../compare-cards" +import { ComparisonSelector } from "../../../../compare-selector" +import { + catalogSlug, + findModelCatalogEntry, + formatCatalogLabName, + getModelCatalog, + type ModelCatalog, + type ModelCatalogEntry, +} from "../../../../model-catalog" +import { + applyThemePreference, + Footer, + getGitHubStars, + Header, + isThemePreference, + themeStorageKey, + type HeaderLink, + type ThemePreference, +} from "../../../../stats-shell" + +const compareFallbackUrl = "https://stats.opencode.ai" +const compareHeaderLinks: readonly HeaderLink[] = [ + { href: "#overview", label: "Overview" }, + { href: "#comparison", label: "Comparison" }, + { href: "#compare-tool", label: "Compare" }, + { href: "#model-comparison", label: "Related" }, +] +const compareFooterLinks: readonly HeaderLink[] = [ + { href: import.meta.env.BASE_URL, label: "Data Home" }, + { href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" }, + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, +] + +type ComparisonModel = { + name: string + lab: string + labName: string + slug: string + catalog: ModelCatalogEntry | null + stats: StatsModelComparisonEntry | null +} +type ComparisonDirection = "higher" | "lower" +type ComparisonCell = { value: string; detail?: string; score?: number } +type ComparisonRow = { + label: string + description: string + direction: ComparisonDirection + cells: [ComparisonCell, ComparisonCell] +} + +const getComparisonData = query( + async (firstLab: string, firstModel: string, secondLab: string, secondModel: string) => { + "use server" + return runtime.runPromise(getStatsModelComparisonData(firstLab, firstModel, secondLab, secondModel)) + }, + "getStatsModelComparisonData", +) + +export default function ModelComparePair() { + const event = getRequestEvent() + event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") + const params = useParams() + const firstLabParam = createMemo(() => params.firstLab ?? "") + const firstModelParam = createMemo(() => params.firstModel ?? "") + const secondLabParam = createMemo(() => params.secondLab ?? "") + const secondModelParam = createMemo(() => params.secondModel ?? "") + const catalog = createAsync(() => getModelCatalog()) + const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam())) + const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam())) + const stats = createAsync(() => { + if (catalog() === undefined || firstCatalog() === undefined || secondCatalog() === undefined) + return Promise.resolve(undefined) + return getComparisonData( + firstCatalog()?.lab ?? firstLabParam(), + firstCatalog()?.slug ?? firstModelParam(), + secondCatalog()?.lab ?? secondLabParam(), + secondCatalog()?.slug ?? secondModelParam(), + ) + }) + const githubStars = createAsync(() => getGitHubStars()) + const [themePreference, setThemePreference] = createSignal("system") + const models = createMemo( + () => + [ + buildComparisonModel(firstLabParam(), firstModelParam(), firstCatalog() ?? null, stats()?.models[0] ?? null), + buildComparisonModel(secondLabParam(), secondModelParam(), secondCatalog() ?? null, stats()?.models[1] ?? null), + ] as const, + ) + const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`) + const description = createMemo( + () => + `Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.`, + ) + const canonicalPath = createMemo( + () => + `${import.meta.env.BASE_URL}compare/${catalogSlug(models()[0].lab)}/${catalogSlug(models()[0].slug)}/${catalogSlug( + models()[1].lab, + )}/${catalogSlug(models()[1].slug)}`, + ) + const canonicalUrl = createMemo(() => + new URL( + canonicalPath(), + event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href), + ).toString(), + ) + const rows = createMemo(() => buildComparisonRows(models()[0], models()[1])) + const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1])) + const selectorModels = createMemo(() => + uniqueCatalogModels([comparisonCatalogEntry(models()[0]), comparisonCatalogEntry(models()[1]), ...(catalog()?.models ?? [])]), + ) + const structuredData = createMemo(() => + JSON.stringify({ + "@context": "https://schema.org", + "@type": "WebPage", + name: title(), + description: description(), + url: canonicalUrl(), + about: models().map((model) => ({ + "@type": "SoftwareApplication", + name: model.name, + applicationCategory: "AI model", + provider: model.labName, + })), + }), + ) + const updateThemePreference = (preference: ThemePreference) => { + applyThemePreference(preference) + setThemePreference(preference) + if (typeof window === "undefined") return + window.localStorage.setItem(themeStorageKey, preference) + } + + onMount(() => { + if (typeof window === "undefined") return + const preference = window.localStorage.getItem(themeStorageKey) + const nextPreference = isThemePreference(preference) ? preference : "system" + applyThemePreference(nextPreference) + setThemePreference(nextPreference) + }) + + return ( +
+ {title()} + + + + + + + + + + + +
+
+
+ +
+

+ Comparison Table.{" "} + Compare usage, cost, limits, and features. +

+ + Loading comparison +

Loading stats for both models.

+
+ } + > + + + +
+

+ Compare Another Pair. Choose two models to compare. +

+ 1} + fallback={ +
+ No models found +

The model list could not be loaded.

+
+ } + > + +
+
+ +
+
+ +
+ ) +} + +function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) { + return ( +
+ + Compare + +
+

+ {props.models[0].name} vs {props.models[1].name} +

+

Compare usage, cost, limits, and features for these two models.

+
+
+ ) +} + +function ComparisonTable(props: { models: readonly [ComparisonModel, ComparisonModel]; rows: ComparisonRow[] }) { + return ( +
+ + + + + + {(model) => } + + + + + {(row) => { + const best = () => bestCellIndex(row) + return ( + + + + {(cell, index) => ( + + )} + + + ) + }} + + +
+ {props.models[0].name} compared with {props.models[1].name} +
Metric{model.name}
+ {row.label} + {row.description} + + {cell.value} + {(detail) => {detail()}} +
+
+ ) +} + +function resolvedCatalogEntry(catalog: ModelCatalog | undefined, lab: string, model: string) { + if (!catalog) return undefined + return findModelCatalogEntry(catalog, model, lab) ?? null +} + +function buildComparisonModel( + labParam: string, + modelParam: string, + catalog: ModelCatalogEntry | null, + stats: StatsModelComparisonEntry | null, +): ComparisonModel { + return { + name: catalog?.name ?? stats?.model ?? formatParamName(modelParam), + lab: catalog?.lab ?? stats?.provider ?? catalogSlug(labParam), + labName: formatCatalogLabName(catalog?.lab ?? stats?.provider ?? labParam), + slug: catalog?.slug ?? stats?.slug ?? catalogSlug(modelParam), + catalog, + stats, + } +} + +function comparisonCatalogEntry(model: ComparisonModel): ModelCatalogEntry { + if (model.catalog) return model.catalog + return { + id: `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`, + lab: catalogSlug(model.lab), + slug: catalogSlug(model.slug), + name: model.name, + modalities: { input: [], output: [] }, + openWeights: false, + reasoning: false, + toolCall: false, + attachment: false, + temperature: false, + weights: [], + benchmarks: [], + } +} + +function uniqueCatalogModels(models: ModelCatalogEntry[]) { + return Object.values( + models.reduce>((result, model) => { + result[model.id] = result[model.id] ?? model + return result + }, {}), + ) +} + +function buildComparisonRows(first: ComparisonModel, second: ComparisonModel): ComparisonRow[] { + return [ + comparisonRow( + "Recent Rank", + "Lower is better.", + { + value: first.stats?.rank == null ? "No usage" : `#${first.stats.rank}`, + score: first.stats?.rank ?? undefined, + }, + { + value: second.stats?.rank == null ? "No usage" : `#${second.stats.rank}`, + score: second.stats?.rank ?? undefined, + }, + "lower", + ), + comparisonRow( + "Token Share", + "Share of recent OpenCode usage.", + { value: first.stats ? formatPercent(first.stats.tokenShare) : "No usage", score: first.stats?.tokenShare }, + { value: second.stats ? formatPercent(second.stats.tokenShare) : "No usage", score: second.stats?.tokenShare }, + "higher", + ), + comparisonRow( + "Tokens", + "Recent token volume.", + { value: first.stats ? formatTokens(first.stats.totals.tokens) : "No usage", score: first.stats?.totals.tokens }, + { + value: second.stats ? formatTokens(second.stats.totals.tokens) : "No usage", + score: second.stats?.totals.tokens, + }, + "higher", + ), + comparisonRow( + "Sessions", + "Recent session count.", + { + value: first.stats ? formatInteger(first.stats.totals.sessions) : "No usage", + score: first.stats?.totals.sessions, + }, + { + value: second.stats ? formatInteger(second.stats.totals.sessions) : "No usage", + score: second.stats?.totals.sessions, + }, + "higher", + ), + comparisonRow( + "Cost / 1M Tokens", + "Lower is better.", + { + value: first.stats ? formatMoney(first.stats.totals.costPerMillion) : "No usage", + score: positiveScore(first.stats?.totals.costPerMillion), + }, + { + value: second.stats ? formatMoney(second.stats.totals.costPerMillion) : "No usage", + score: positiveScore(second.stats?.totals.costPerMillion), + }, + "lower", + ), + comparisonRow( + "Cost / Session", + "Lower is better.", + { + value: first.stats ? formatSessionCost(first.stats.totals.costPerSession) : "No usage", + score: positiveScore(first.stats?.totals.costPerSession), + }, + { + value: second.stats ? formatSessionCost(second.stats.totals.costPerSession) : "No usage", + score: positiveScore(second.stats?.totals.costPerSession), + }, + "lower", + ), + comparisonRow( + "Cache Ratio", + "Higher is better.", + { value: first.stats ? formatPercent(first.stats.totals.cacheRatio) : "No usage", score: first.stats?.totals.cacheRatio }, + { + value: second.stats ? formatPercent(second.stats.totals.cacheRatio) : "No usage", + score: second.stats?.totals.cacheRatio, + }, + "higher", + ), + comparisonRow( + "Context Window", + "Higher limit is better.", + { + value: formatCatalogLimit(first.catalog?.limit?.context), + score: first.catalog?.limit?.context, + }, + { + value: formatCatalogLimit(second.catalog?.limit?.context), + score: second.catalog?.limit?.context, + }, + "higher", + ), + comparisonRow( + "Output Limit", + "Higher limit is better.", + { + value: formatCatalogLimit(first.catalog?.limit?.output), + score: first.catalog?.limit?.output, + }, + { + value: formatCatalogLimit(second.catalog?.limit?.output), + score: second.catalog?.limit?.output, + }, + "higher", + ), + comparisonRow( + "Release Date", + "Newer release is highlighted.", + { + value: formatCatalogDate(first.catalog?.releaseDate), + score: catalogDateScore(first.catalog?.releaseDate), + }, + { + value: formatCatalogDate(second.catalog?.releaseDate), + score: catalogDateScore(second.catalog?.releaseDate), + }, + "higher", + ), + comparisonRow( + "Reasoning", + "Supports reasoning.", + booleanCell(first.catalog?.reasoning), + booleanCell(second.catalog?.reasoning), + "higher", + ), + comparisonRow( + "Tool Calling", + "Supports tool calls.", + booleanCell(first.catalog?.toolCall), + booleanCell(second.catalog?.toolCall), + "higher", + ), + comparisonRow( + "Attachments", + "Supports attachments.", + booleanCell(first.catalog?.attachment), + booleanCell(second.catalog?.attachment), + "higher", + ), + comparisonRow( + "Open Weights", + "Open weights available.", + booleanCell(first.catalog?.openWeights), + booleanCell(second.catalog?.openWeights), + "higher", + ), + ] +} + +function comparisonRow( + label: string, + description: string, + first: ComparisonCell, + second: ComparisonCell, + direction: ComparisonDirection, +): ComparisonRow { + return { label, description, direction, cells: [first, second] } +} + +function bestCellIndex(row: ComparisonRow) { + const [first, second] = row.cells.map((cell) => cell.score) + if (first === undefined || second === undefined || first === second) return undefined + if (row.direction === "higher") return first > second ? 0 : 1 + return first < second ? 0 : 1 +} + +function buildRelatedPairs( + catalog: ModelCatalog | undefined, + first: ComparisonModel, + second: ComparisonModel, +): ComparisonPair[] { + const current = [comparisonRef(first), comparisonRef(second)] as const + const alternatives = (catalog?.models ?? []) + .filter((model) => model.id !== first.catalog?.id && model.id !== second.catalog?.id) + .slice(0, 4) + .map(modelRefFromCatalog) + + return uniqueComparisonPairs([ + ...alternatives.slice(0, 3).flatMap((model, index) => [ + { first: current[0], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" }, + { first: current[1], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" }, + ]), + ]).slice(0, 6) +} + +function comparisonRef(model: ComparisonModel): ComparisonModelRef { + return { + name: model.name, + lab: model.lab, + slug: model.slug, + labName: model.labName, + metric: model.stats ? `#${model.stats.rank}` : "Catalog", + } +} + +function positiveScore(value: number | undefined) { + return value && value > 0 ? value : undefined +} + +function booleanCell(value: boolean | undefined): ComparisonCell { + if (value === undefined) return { value: "Unknown" } + return { value: value ? "Yes" : "No", score: value ? 1 : 0 } +} + +function catalogDateScore(value: string | undefined) { + if (!value) return undefined + const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value) + if (!match) return undefined + return Date.UTC(Number(match[1]), match[2] ? Number(match[2]) - 1 : 0, match[3] ? Number(match[3]) : 1) +} + +function formatParamName(value: string) { + return value + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()) + .trim() +} + +function formatCatalogLimit(value: number | undefined) { + return value === undefined ? "Unknown" : formatTokens(value) +} + +function formatCatalogDate(value: string | undefined) { + if (!value) return "Unknown" + const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value) + if (!match) return value + const year = Number(match[1]) + const month = match[2] ? Number(match[2]) - 1 : 0 + const day = match[3] ? Number(match[3]) : 1 + return new Intl.DateTimeFormat("en", { + month: match[2] ? "short" : undefined, + day: match[3] ? "numeric" : undefined, + year: "numeric", + timeZone: "UTC", + }).format(new Date(Date.UTC(year, month, day))) +} + +function formatTokens(value: number) { + if (value >= 1_000_000_000_000) + return `${trimNumber(value / 1_000_000_000_000, value >= 10_000_000_000_000 ? 0 : 1)}T` + if (value >= 1_000_000_000) return `${trimNumber(value / 1_000_000_000, value >= 10_000_000_000 ? 0 : 1)}B` + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M` + if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K` + return String(Math.round(value)) +} + +function formatInteger(value: number) { + return new Intl.NumberFormat("en").format(value) +} + +function formatPercent(value: number) { + return `${trimNumber(value, value >= 10 ? 1 : 2)}%` +} + +function formatMoney(value: number) { + if (value >= 1) return `$${trimNumber(value, 2)}` + if (value > 0) return `$${value.toFixed(4)}` + return "$0" +} + +function formatSessionCost(value: number) { + if (value >= 1) return `$${trimNumber(value, 2)}` + if (value >= 0.01) return `$${value.toFixed(2)}` + if (value > 0) return `$${value.toFixed(4)}` + return "$0" +} + +function trimNumber(value: number, digits: number) { + return Number(value.toFixed(digits)).toLocaleString("en") +} diff --git a/packages/stats/app/src/routes/compare/index.tsx b/packages/stats/app/src/routes/compare/index.tsx new file mode 100644 index 0000000000..23e134225f --- /dev/null +++ b/packages/stats/app/src/routes/compare/index.tsx @@ -0,0 +1,148 @@ +import "../index.css" +import { Link, Meta, Title } from "@solidjs/meta" +import { createAsync } from "@solidjs/router" +import { createMemo, createSignal, onMount, Show } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards" +import { ComparisonSelector } from "../compare-selector" +import { getModelCatalog, type ModelCatalogEntry } from "../model-catalog" +import { + applyThemePreference, + Footer, + getGitHubStars, + Header, + isThemePreference, + themeStorageKey, + type HeaderLink, + type ThemePreference, +} from "../stats-shell" + +const compareFallbackUrl = "https://stats.opencode.ai" +const compareTitle = "AI Model Comparison" +const compareDescription = + "Compare AI models used in OpenCode by context, output, release date, usage, rank, token share, and cost." +const compareHeaderLinks: readonly HeaderLink[] = [ + { href: "#compare-tool", label: "Compare" }, + { href: "#model-comparison", label: "Popular" }, +] +const compareFooterLinks: readonly HeaderLink[] = [ + { href: import.meta.env.BASE_URL, label: "Data Home" }, + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, + { href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" }, +] + +export default function ModelCompareIndex() { + const event = getRequestEvent() + event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") + const catalog = createAsync(() => getModelCatalog()) + const githubStars = createAsync(() => getGitHubStars()) + const [themePreference, setThemePreference] = createSignal("system") + const compareUrl = createMemo(() => + new URL( + `${import.meta.env.BASE_URL}compare`, + event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href), + ).toString(), + ) + const featuredModels = createMemo(() => (catalog()?.models ?? []).slice(0, 120)) + const pairs = createMemo(() => buildPopularPairs(featuredModels())) + const updateThemePreference = (preference: ThemePreference) => { + applyThemePreference(preference) + setThemePreference(preference) + if (typeof window === "undefined") return + window.localStorage.setItem(themeStorageKey, preference) + } + + onMount(() => { + if (typeof window === "undefined") return + const preference = window.localStorage.getItem(themeStorageKey) + const nextPreference = isThemePreference(preference) ? preference : "system" + applyThemePreference(nextPreference) + setThemePreference(nextPreference) + }) + + return ( +
+ {compareTitle} + + + + + + + + + + +
+
+
+
+ + Data + +
+
+ /compare +

Model Comparison

+

Choose two models and compare usage, cost, limits, and features.

+
+
+ Model Pairs + {formatPairCount(featuredModels().length)} +

Available pairings from the model list.

+
+
+
+ +
+
+
+
+ ) +} + +function buildPopularPairs(models: ModelCatalogEntry[]) { + return uniqueComparisonPairs( + ( + [ + [0, 1, "Popular pair"], + [0, 2, "Top alternative"], + [1, 2, "Nearby pair"], + [2, 3, "Recent model pair"], + [3, 4, "Model pair"], + [4, 5, "Model pair"], + ] as const + ).flatMap(([firstIndex, secondIndex, detail]) => { + const first = models[firstIndex] + const second = models[secondIndex] + return first && second ? [{ first: modelRefFromCatalog(first), second: modelRefFromCatalog(second), detail }] : [] + }), + ) +} + +function formatPairCount(count: number) { + if (count < 2) return "0" + return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format((count * (count - 1)) / 2) +} diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 2b57338bf8..b52aa51e08 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -5252,6 +5252,243 @@ color: var(--stats-text); } +[data-page="stats"] [data-section="model-panel"][data-variant="compact"] { + padding-top: 56px; + padding-bottom: 56px; +} + +[data-page="stats"] [data-component="comparison-card-grid"] { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +[data-page="stats"] [data-component="comparison-card"] { + display: grid; + align-content: space-between; + gap: 18px; + min-height: 168px; + min-width: 0; + padding: 16px; + border: 1px solid var(--stats-line); + background: var(--stats-layer); + color: var(--stats-text); + text-decoration: none; +} + +[data-page="stats"] [data-component="comparison-card"]:hover, +[data-page="stats"] [data-component="comparison-card"]:focus-visible { + border-color: var(--stats-text); + outline: none; + background: var(--stats-bg); + text-decoration: none; +} + +[data-page="stats"] [data-component="comparison-card"] span, +[data-page="stats"] [data-component="comparison-card"] p, +[data-page="stats"] [data-component="comparison-card"] small { + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.35; +} + +[data-page="stats"] [data-component="comparison-card"] strong { + min-width: 0; + color: var(--stats-text); + font-size: 20px; + font-weight: 500; + line-height: 1.08; + overflow-wrap: anywhere; +} + +[data-page="stats"] [data-component="comparison-card"] em { + color: var(--stats-faint); + font: inherit; +} + +[data-page="stats"] [data-component="comparison-card"] p { + display: flex; + align-items: center; + gap: 8px; + margin: 0; +} + +[data-page="stats"] [data-component="comparison-card"] b { + min-width: 0; + overflow: hidden; + font: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-page="stats"] [data-component="comparison-card"] i { + flex: 0 0 auto; + width: 16px; + height: 1px; + background: var(--stats-line-strong); +} + +[data-page="stats"] [data-component="comparison-selector"] { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 8px; + align-items: end; + max-width: 920px; +} + +[data-page="stats"] [data-component="comparison-selector"] label { + display: grid; + gap: 8px; + min-width: 0; +} + +[data-page="stats"] [data-component="comparison-selector"] label span { + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.2; +} + +[data-page="stats"] [data-component="comparison-selector"] select, +[data-page="stats"] [data-component="comparison-selector"] button { + box-sizing: border-box; + height: 40px; + min-width: 0; + border: 1px solid var(--stats-line); + border-radius: 0; + appearance: none; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1; +} + +[data-page="stats"] [data-component="comparison-selector"] select { + width: 100%; + padding: 0 36px 0 12px; + background: + linear-gradient(45deg, transparent 50%, var(--stats-muted) 50%) calc(100% - 18px) 17px / 5px 5px no-repeat, + linear-gradient(135deg, var(--stats-muted) 50%, transparent 50%) calc(100% - 13px) 17px / 5px 5px no-repeat, + var(--stats-bg); + color: var(--stats-text); +} + +[data-page="stats"] [data-component="comparison-selector"] button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 14px; + background: var(--stats-text); + color: var(--stats-bg); + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} + +[data-page="stats"] [data-component="comparison-selector"] button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +[data-page="stats"] [data-component="comparison-selector"] select:focus-visible, +[data-page="stats"] [data-component="comparison-selector"] button:focus-visible { + outline: 2px solid var(--stats-accent); + outline-offset: 2px; +} + +[data-page="stats"] [data-component="comparison-table-wrap"] { + overflow-x: auto; + border: 1px solid var(--stats-line); + background: var(--stats-layer); +} + +[data-page="stats"] [data-component="comparison-table"] { + width: 100%; + min-width: 720px; + border-collapse: collapse; + color: var(--stats-text); + font-size: 13px; + line-height: 1.35; +} + +[data-page="stats"] [data-component="comparison-table"] caption { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +[data-page="stats"] [data-component="comparison-table"] th, +[data-page="stats"] [data-component="comparison-table"] td { + min-width: 0; + padding: 14px 16px; + border-bottom: 1px solid var(--stats-line); + border-left: 1px solid var(--stats-line); + text-align: left; + vertical-align: top; +} + +[data-page="stats"] [data-component="comparison-table"] th:first-child { + border-left: 0; +} + +[data-page="stats"] [data-component="comparison-table"] thead th { + color: var(--stats-muted); + font-size: 11px; + font-weight: 600; + line-height: 1.2; + text-transform: uppercase; +} + +[data-page="stats"] [data-component="comparison-table"] tbody tr:last-child th, +[data-page="stats"] [data-component="comparison-table"] tbody tr:last-child td { + border-bottom: 0; +} + +[data-page="stats"] [data-component="comparison-table"] tbody th { + width: 34%; + background: var(--stats-bg); +} + +[data-page="stats"] [data-component="comparison-table"] tbody th strong, +[data-page="stats"] [data-component="comparison-table"] td strong { + display: block; + color: var(--stats-text); + font-size: 13px; + font-weight: 600; + line-height: 1.25; + overflow-wrap: anywhere; +} + +[data-page="stats"] [data-component="comparison-table"] tbody th span, +[data-page="stats"] [data-component="comparison-table"] td span { + display: block; + margin-top: 5px; + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.35; +} + +[data-page="stats"] [data-component="comparison-table"] td[data-best="true"] { + position: relative; + background: var(--stats-bg); + box-shadow: inset 0 0 0 1px var(--stats-accent); +} + +[data-page="stats"] [data-component="comparison-table"] td[data-best="true"]::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 3px; + content: ""; + background: var(--stats-accent); +} + [data-page="stats"][data-theme="dark"], :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) { color-scheme: dark; @@ -5784,10 +6021,19 @@ [data-page="stats"] [data-component="model-metric-grid"], [data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"], [data-page="stats"] [data-component="model-efficiency-grid"], - [data-page="stats"] [data-component="lab-model-grid"] { + [data-page="stats"] [data-component="lab-model-grid"], + [data-page="stats"] [data-component="comparison-card-grid"] { grid-template-columns: 1fr 1fr; } + [data-page="stats"] [data-component="comparison-selector"] { + grid-template-columns: 1fr; + } + + [data-page="stats"] [data-component="comparison-selector"] button { + width: 100%; + } + [data-page="stats"] [data-component="model-peer-list"] a { grid-template-columns: 32px 32px minmax(0, 1fr) minmax(64px, auto); gap: 12px; @@ -6212,10 +6458,15 @@ [data-page="stats"] [data-component="model-metric-grid"], [data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"], [data-page="stats"] [data-component="model-efficiency-grid"], - [data-page="stats"] [data-component="lab-model-grid"] { + [data-page="stats"] [data-component="lab-model-grid"], + [data-page="stats"] [data-component="comparison-card-grid"] { grid-template-columns: 1fr; } + [data-page="stats"] [data-component="comparison-table"] { + min-width: 640px; + } + [data-page="stats"] [data-component="model-metric"] { min-height: 112px; } diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index a30986dd96..19ca38b4e1 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -34,6 +34,7 @@ import { localizedUrl } from "../lib/language" import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog" import { SectionHeading } from "./section-heading" import { setStatsPageCacheHeaders } from "./stats-cache" +import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards" import { applyThemePreference, Footer, @@ -48,6 +49,12 @@ import { const products = ["All Users", "Zen", "Go"] as const const tokenProducts = ["Zen", "Go"] as const const ranges = ["1D", "1W", "2W", "1M", "2M"] as const +const comparisonPairIndexes = [ + [0, 1, "Top two by recent usage"], + [0, 2, "Leader vs challenger"], + [1, 2, "Adjacent leaderboard pair"], + [2, 3, "Top model alternative"], +] as const const statsUnfurlPath = "banner.jpg" const usageColors = [ "#ed6aff", @@ -183,11 +190,20 @@ export default function StatsHome() { + )} -