@@ -796,6 +807,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 9d71f7b644..9e95b3b05c 100644
--- a/packages/stats/app/src/routes/[lab]/index.tsx
+++ b/packages/stats/app/src/routes/[lab]/index.tsx
@@ -17,6 +17,11 @@ import {
type ModelCatalogLab,
} from "../model-catalog"
import { runStatsEffect } from "../../stats-runtime"
+import {
+ ComparisonCardsSection,
+ modelRefFromCatalog,
+ uniqueComparisonPairs,
+} from "../compare-cards"
import {
applyThemePreference,
Footer,
@@ -120,6 +125,11 @@ export default function StatsLab() {
+
>
)}
@@ -129,6 +139,7 @@ export default function StatsLab() {
themePreference={themePreference()}
onThemePreferenceChange={updateThemePreference}
links={labFooterLinks}
+ bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }}
/>
@@ -374,6 +385,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 }] : []
+ }),
+ )
+}
+
function formatCatalogLimit(value: number | undefined) {
return value === undefined ? "Unknown" : formatTokens(value)
}
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 (
+
+ )
+}
+
+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.
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) {
+ return (
+