chore: merge dev into v2 (#36312)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com>
This commit is contained in:
parent
9028c2d8f8
commit
43ecf3ff1b
82 changed files with 4519 additions and 1231 deletions
1150
packages/stats/app/src/component/model-compare-detail.tsx
Normal file
1150
packages/stats/app/src/component/model-compare-detail.tsx
Normal file
File diff suppressed because it is too large
Load diff
286
packages/stats/app/src/lib/comparison-pages.ts
Normal file
286
packages/stats/app/src/lib/comparison-pages.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { catalogSlug, findModelCatalogEntry, type ModelCatalog, type ModelCatalogEntry } from "../routes/model-catalog"
|
||||
|
||||
type ComparisonFamilyDefinition = {
|
||||
slug: string
|
||||
name: string
|
||||
lab: string
|
||||
prefixes: string[]
|
||||
aliases?: string[]
|
||||
preferredFamilies?: string[]
|
||||
}
|
||||
|
||||
export type ResolvedComparisonFamily = ComparisonFamilyDefinition & {
|
||||
model: ModelCatalogEntry
|
||||
}
|
||||
|
||||
export const comparisonFamilies: ComparisonFamilyDefinition[] = [
|
||||
{
|
||||
slug: "gpt",
|
||||
name: "GPT",
|
||||
lab: "openai",
|
||||
prefixes: ["gpt", "o"],
|
||||
aliases: ["openai"],
|
||||
preferredFamilies: ["gpt", "o"],
|
||||
},
|
||||
{
|
||||
slug: "claude",
|
||||
name: "Claude",
|
||||
lab: "anthropic",
|
||||
prefixes: ["claude"],
|
||||
aliases: ["anthropic"],
|
||||
preferredFamilies: ["claude-sonnet", "claude-opus"],
|
||||
},
|
||||
{
|
||||
slug: "gemini",
|
||||
name: "Gemini",
|
||||
lab: "google",
|
||||
prefixes: ["gemini"],
|
||||
aliases: ["google"],
|
||||
preferredFamilies: ["gemini-pro", "gemini-flash", "gemini"],
|
||||
},
|
||||
{
|
||||
slug: "deepseek",
|
||||
name: "DeepSeek",
|
||||
lab: "deepseek",
|
||||
prefixes: ["deepseek"],
|
||||
preferredFamilies: ["deepseek-thinking", "deepseek"],
|
||||
},
|
||||
{
|
||||
slug: "qwen",
|
||||
name: "Qwen",
|
||||
lab: "alibaba",
|
||||
prefixes: ["qwen"],
|
||||
aliases: ["alibaba"],
|
||||
preferredFamilies: ["qwen"],
|
||||
},
|
||||
{
|
||||
slug: "glm",
|
||||
name: "GLM",
|
||||
lab: "zhipuai",
|
||||
prefixes: ["glm"],
|
||||
aliases: ["zhipu", "zhipuai", "zai"],
|
||||
preferredFamilies: ["glm"],
|
||||
},
|
||||
{
|
||||
slug: "kimi",
|
||||
name: "Kimi",
|
||||
lab: "moonshotai",
|
||||
prefixes: ["kimi"],
|
||||
aliases: ["moonshot", "moonshotai"],
|
||||
preferredFamilies: ["kimi-k2", "kimi-thinking"],
|
||||
},
|
||||
{
|
||||
slug: "minimax",
|
||||
name: "MiniMax",
|
||||
lab: "minimax",
|
||||
prefixes: ["minimax"],
|
||||
},
|
||||
{
|
||||
slug: "grok",
|
||||
name: "Grok",
|
||||
lab: "xai",
|
||||
prefixes: ["grok"],
|
||||
aliases: ["xai"],
|
||||
preferredFamilies: ["grok"],
|
||||
},
|
||||
{
|
||||
slug: "mistral",
|
||||
name: "Mistral",
|
||||
lab: "mistral",
|
||||
prefixes: ["mistral", "magistral", "devstral", "codestral"],
|
||||
preferredFamilies: ["mistral-large", "mistral-medium", "mistral-small"],
|
||||
},
|
||||
{
|
||||
slug: "llama",
|
||||
name: "Llama",
|
||||
lab: "meta",
|
||||
prefixes: ["llama"],
|
||||
aliases: ["meta"],
|
||||
},
|
||||
{
|
||||
slug: "nemotron",
|
||||
name: "Nemotron",
|
||||
lab: "nvidia",
|
||||
prefixes: ["nemotron", "llama-nemotron"],
|
||||
aliases: ["nvidia"],
|
||||
},
|
||||
{
|
||||
slug: "mimo",
|
||||
name: "MiMo",
|
||||
lab: "xiaomi",
|
||||
prefixes: ["mimo"],
|
||||
aliases: ["xiaomi"],
|
||||
},
|
||||
{
|
||||
slug: "command",
|
||||
name: "Command",
|
||||
lab: "cohere",
|
||||
prefixes: ["command"],
|
||||
aliases: ["cohere"],
|
||||
preferredFamilies: ["command-a", "command-r"],
|
||||
},
|
||||
{
|
||||
slug: "sonar",
|
||||
name: "Sonar",
|
||||
lab: "perplexity",
|
||||
prefixes: ["sonar"],
|
||||
aliases: ["perplexity"],
|
||||
preferredFamilies: ["sonar-pro", "sonar-reasoning", "sonar"],
|
||||
},
|
||||
{
|
||||
slug: "longcat",
|
||||
name: "LongCat",
|
||||
lab: "meituan",
|
||||
prefixes: ["longcat"],
|
||||
aliases: ["meituan"],
|
||||
},
|
||||
{
|
||||
slug: "step",
|
||||
name: "Step",
|
||||
lab: "stepfun",
|
||||
prefixes: ["step"],
|
||||
aliases: ["stepfun"],
|
||||
},
|
||||
{
|
||||
slug: "mai",
|
||||
name: "MAI",
|
||||
lab: "microsoft",
|
||||
prefixes: ["mai"],
|
||||
aliases: ["microsoft"],
|
||||
},
|
||||
]
|
||||
|
||||
export function resolveComparisonFamily(catalog: ModelCatalog, value: string) {
|
||||
const family = findComparisonFamily(value)
|
||||
if (!family) return undefined
|
||||
const model = comparisonFamilyCandidates(catalog, family.slug)[0]
|
||||
if (!model) return undefined
|
||||
return { ...family, model } satisfies ResolvedComparisonFamily
|
||||
}
|
||||
|
||||
export function findComparisonFamily(value: string) {
|
||||
const slug = catalogSlug(value)
|
||||
return comparisonFamilies.find((family) => family.slug === slug || family.aliases?.includes(slug))
|
||||
}
|
||||
|
||||
export function comparisonFamilyCandidates(catalog: ModelCatalog, value: string) {
|
||||
const family = findComparisonFamily(value)
|
||||
if (!family) return []
|
||||
const matches = catalog.models
|
||||
.filter((model) => model.lab === family.lab && isFamilyModel(model, family) && isGeneralComparisonModel(model))
|
||||
.toSorted((a, b) => comparisonFamilyModelSort(a, b, family))
|
||||
return matches.filter((model) => !isDuplicateAliasModel(model, matches))
|
||||
}
|
||||
|
||||
export function comparisonSitemapModels(
|
||||
catalog: ModelCatalog,
|
||||
leaderboard: { model: string; provider: string }[] = [],
|
||||
) {
|
||||
return uniqueModels([
|
||||
...comparisonFamilies.flatMap((family) => comparisonFamilyCandidates(catalog, family.slug).slice(0, 2)),
|
||||
...leaderboard.flatMap((entry) => {
|
||||
const model =
|
||||
findModelCatalogEntry(catalog, entry.model, entry.provider) ?? findModelCatalogEntry(catalog, entry.model)
|
||||
return model && isGeneralComparisonModel(model) ? [model] : []
|
||||
}),
|
||||
]).toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export function canonicalModelComparisonPath(first: ModelCatalogEntry, second: ModelCatalogEntry) {
|
||||
const models = [first, second].toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
return `/data/compare/${models[0].lab}/${models[0].slug}/${models[1].lab}/${models[1].slug}`
|
||||
}
|
||||
|
||||
export function canonicalFamilyComparisonPath(first: ResolvedComparisonFamily, second: ResolvedComparisonFamily) {
|
||||
const families = [first, second].toSorted((a, b) => a.slug.localeCompare(b.slug))
|
||||
return `/data/compare/${families[0].slug}/${families[1].slug}`
|
||||
}
|
||||
|
||||
export function latestFamilyComparisonPath(catalog: ModelCatalog, first: ModelCatalogEntry, second: ModelCatalogEntry) {
|
||||
const firstFamily = comparisonFamilyForModel(catalog, first)
|
||||
const secondFamily = comparisonFamilyForModel(catalog, second)
|
||||
if (!firstFamily || !secondFamily || firstFamily.slug === secondFamily.slug) return undefined
|
||||
if (firstFamily.model.id !== first.id || secondFamily.model.id !== second.id) return undefined
|
||||
return canonicalFamilyComparisonPath(firstFamily, secondFamily)
|
||||
}
|
||||
|
||||
export function comparisonFamilyForModel(catalog: ModelCatalog, model: ModelCatalogEntry) {
|
||||
const family = comparisonFamilies.find(
|
||||
(candidate) => candidate.lab === model.lab && isFamilyModel(model, candidate) && isGeneralComparisonModel(model),
|
||||
)
|
||||
if (!family) return undefined
|
||||
const latest = comparisonFamilyCandidates(catalog, family.slug)[0]
|
||||
if (!latest) return undefined
|
||||
return { ...family, model: latest } satisfies ResolvedComparisonFamily
|
||||
}
|
||||
|
||||
function isFamilyModel(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
|
||||
const values = [model.family, model.slug, model.name]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map(catalogSlug)
|
||||
return family.prefixes.some((prefix) => values.some((value) => value === prefix || value.startsWith(`${prefix}-`)))
|
||||
}
|
||||
|
||||
function isGeneralComparisonModel(model: ModelCatalogEntry) {
|
||||
const input = model.modalities.input.map(catalogSlug)
|
||||
const output = model.modalities.output.map(catalogSlug)
|
||||
if (!input.includes("text") || !output.includes("text")) return false
|
||||
return !/(?:^|-)(?:audio|embedding|guard|image|moderation|omni|rerank|safety|speech|transcribe|tts|vision)(?:-|$)/.test(
|
||||
model.slug,
|
||||
)
|
||||
}
|
||||
|
||||
function comparisonFamilyModelSort(
|
||||
first: ModelCatalogEntry,
|
||||
second: ModelCatalogEntry,
|
||||
family: ComparisonFamilyDefinition,
|
||||
) {
|
||||
return (
|
||||
displayDateTime(second.releaseDate ?? second.lastUpdated) -
|
||||
displayDateTime(first.releaseDate ?? first.lastUpdated) ||
|
||||
preferredFamilyIndex(first, family) - preferredFamilyIndex(second, family) ||
|
||||
modelVariantPenalty(first) - modelVariantPenalty(second) ||
|
||||
first.slug.length - second.slug.length ||
|
||||
first.name.localeCompare(second.name)
|
||||
)
|
||||
}
|
||||
|
||||
function preferredFamilyIndex(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
|
||||
const index = family.preferredFamilies?.indexOf(catalogSlug(model.family ?? "")) ?? -1
|
||||
return index === -1 ? (family.preferredFamilies?.length ?? 0) : index
|
||||
}
|
||||
|
||||
function modelVariantPenalty(model: ModelCatalogEntry) {
|
||||
return /(?:highspeed|latest|preview|turbo|ultraspeed)/.test(model.slug) ? 1 : 0
|
||||
}
|
||||
|
||||
function isDuplicateAliasModel(model: ModelCatalogEntry, models: ModelCatalogEntry[]) {
|
||||
if (!/(?:-latest|-highspeed|-ultraspeed)$/.test(model.slug)) return false
|
||||
return models.some(
|
||||
(candidate) =>
|
||||
candidate.id !== model.id &&
|
||||
candidate.releaseDate === model.releaseDate &&
|
||||
candidate.family === model.family &&
|
||||
!/(?:-latest|-highspeed|-ultraspeed)$/.test(candidate.slug),
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueModels(models: ModelCatalogEntry[]) {
|
||||
return models.reduce<{ ids: Set<string>; models: ModelCatalogEntry[] }>(
|
||||
(result, model) => {
|
||||
if (result.ids.has(model.id)) return result
|
||||
result.ids.add(model.id)
|
||||
result.models.push(model)
|
||||
return result
|
||||
},
|
||||
{ ids: new Set(), models: [] },
|
||||
).models
|
||||
}
|
||||
|
||||
function displayDateTime(value: string | undefined) {
|
||||
if (!value) return 0
|
||||
const date = new Date(value)
|
||||
if (!Number.isNaN(date.getTime())) return date.getTime()
|
||||
const year = Number(value.match(/\d{4}/)?.[0] ?? 0)
|
||||
return Number.isFinite(year) ? year : 0
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { For, Show } from "solid-js"
|
||||
import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog"
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export type ComparisonPair = {
|
|||
first: ComparisonModelRef
|
||||
second: ComparisonModelRef
|
||||
detail: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
|
||||
|
|
@ -30,6 +32,11 @@ export function comparisonHref(first: ComparisonModelRef, second: ComparisonMode
|
|||
)}/${catalogSlug(second.slug)}`
|
||||
}
|
||||
|
||||
export function canonicalComparisonHref(first: ComparisonModelRef, second: ComparisonModelRef) {
|
||||
const models = [first, second].toSorted((a, b) => modelKey(a).localeCompare(modelKey(b)))
|
||||
return comparisonHref(models[0], models[1])
|
||||
}
|
||||
|
||||
export function uniqueComparisonPairs(pairs: ComparisonPair[]) {
|
||||
return pairs.reduce<{ keys: Set<string>; pairs: ComparisonPair[] }>(
|
||||
(result, pair) => {
|
||||
|
|
@ -48,34 +55,25 @@ export function ComparisonCardsSection(props: {
|
|||
title?: string
|
||||
description?: string
|
||||
compact?: boolean
|
||||
variant?: "panel" | "featured"
|
||||
}) {
|
||||
const featured = () => props.variant === "featured"
|
||||
const pairs = () => (featured() ? props.pairs.slice(0, 4) : props.pairs)
|
||||
|
||||
return (
|
||||
<Show when={props.pairs.length > 0}>
|
||||
<section id="model-comparison" data-section="model-panel" data-variant={props.compact ? "compact" : undefined}>
|
||||
<section
|
||||
id="model-comparison"
|
||||
data-section={featured() ? "compare-home-related" : "model-panel"}
|
||||
data-variant={!featured() && props.compact ? "compact" : undefined}
|
||||
>
|
||||
<p data-slot="section-title">
|
||||
<strong>{props.title ?? "Model Comparisons"}.</strong>{" "}
|
||||
<span>{props.description ?? "Compare usage, cost, limits, and features."}</span>
|
||||
</p>
|
||||
<div data-component="comparison-card-grid">
|
||||
<For each={props.pairs}>
|
||||
{(pair) => (
|
||||
<a data-component="comparison-card" href={comparisonHref(pair.first, pair.second)}>
|
||||
<span>{pair.detail}</span>
|
||||
<strong>
|
||||
{pair.first.name} <em>vs</em> {pair.second.name}
|
||||
</strong>
|
||||
<p>
|
||||
<b>{pair.first.labName ?? formatCatalogLabName(pair.first.lab)}</b>
|
||||
<i />
|
||||
<b>{pair.second.labName ?? formatCatalogLabName(pair.second.lab)}</b>
|
||||
</p>
|
||||
<Show when={pair.first.metric || pair.second.metric}>
|
||||
<small>
|
||||
{pair.first.metric ?? "Listed"} / {pair.second.metric ?? "Listed"}
|
||||
</small>
|
||||
</Show>
|
||||
</a>
|
||||
)}
|
||||
<div data-component={featured() ? "compare-home-card-grid" : "comparison-card-grid"}>
|
||||
<For each={pairs()}>
|
||||
{(pair) => (featured() ? <FeaturedComparisonCard pair={pair} /> : <ComparisonPanelCard pair={pair} />)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -83,6 +81,90 @@ export function ComparisonCardsSection(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
|
||||
return (
|
||||
<a
|
||||
data-component="compare-home-card"
|
||||
href={canonicalComparisonHref(props.pair.first, props.pair.second)}
|
||||
aria-label={`${props.pair.detail}: ${props.pair.first.name} vs ${props.pair.second.name}`}
|
||||
>
|
||||
<span data-slot="compare-home-card-head">
|
||||
<span>
|
||||
<strong>{props.pair.detail}</strong>
|
||||
<em>{props.pair.description ?? `${props.pair.first.name} vs ${props.pair.second.name}`}</em>
|
||||
</span>
|
||||
<ComparisonCardIcon />
|
||||
</span>
|
||||
<span data-slot="compare-home-card-divider" aria-hidden="true" />
|
||||
<span data-slot="compare-home-card-models">
|
||||
<span>{props.pair.first.name}</span>
|
||||
<i aria-hidden="true">·</i>
|
||||
<span>{props.pair.second.name}</span>
|
||||
</span>
|
||||
<span data-slot="compare-home-card-avatars" aria-hidden="true">
|
||||
<ComparisonLabLogo model={props.pair.first} />
|
||||
<ComparisonLabLogo model={props.pair.second} />
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonCardIcon() {
|
||||
return (
|
||||
<b aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.9509 12.9884L14.4069 14.4444M2.44431 2.44434H6.44431V6.44434H2.44431V2.44434ZM2.44431 9.55542H6.44431V13.5554H2.44431V9.55542ZM9.55539 2.44434H13.5554V6.44434H9.55539V2.44434ZM13.5554 11.5554C13.5554 12.66 12.66 13.5554 11.5554 13.5554C10.4508 13.5554 9.55539 12.66 9.55539 11.5554C9.55539 10.4509 10.4508 9.55542 11.5554 9.55542C12.66 9.55542 13.5554 10.4509 13.5554 11.5554Z"
|
||||
stroke="#808080"
|
||||
/>
|
||||
</svg>
|
||||
</b>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonPanelCard(props: { pair: ComparisonPair }) {
|
||||
return (
|
||||
<a data-component="comparison-card" href={canonicalComparisonHref(props.pair.first, props.pair.second)}>
|
||||
<span>{props.pair.detail}</span>
|
||||
<strong>
|
||||
{props.pair.first.name} <em>vs</em> {props.pair.second.name}
|
||||
</strong>
|
||||
<p>
|
||||
<b>{props.pair.first.labName ?? formatCatalogLabName(props.pair.first.lab)}</b>
|
||||
<i />
|
||||
<b>{props.pair.second.labName ?? formatCatalogLabName(props.pair.second.lab)}</b>
|
||||
</p>
|
||||
<Show when={props.pair.first.metric || props.pair.second.metric}>
|
||||
<small>
|
||||
{props.pair.first.metric ?? "Listed"} / {props.pair.second.metric ?? "Listed"}
|
||||
</small>
|
||||
</Show>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonLabLogo(props: { model: ComparisonModelRef }) {
|
||||
const iconId = () => providerIconId(props.model.lab)
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="compare-home-avatar"
|
||||
data-lab={iconId()}
|
||||
data-size="small"
|
||||
aria-label={props.model.labName ?? formatCatalogLabName(props.model.lab)}
|
||||
>
|
||||
<ProviderIcon aria-hidden="true" id={iconId()} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function modelKey(model: ComparisonModelRef) {
|
||||
return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
|
||||
}
|
||||
|
||||
function providerIconId(provider: string) {
|
||||
const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
if (id === "moonshot") return "moonshotai"
|
||||
if (id === "zhipu") return "zhipuai"
|
||||
return id
|
||||
}
|
||||
|
|
|
|||
391
packages/stats/app/src/routes/compare-radar.tsx
Normal file
391
packages/stats/app/src/routes/compare-radar.tsx
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import type { ModelCatalogBenchmark, ModelCatalogEntry } from "./model-catalog"
|
||||
|
||||
const radarRingCount = 5
|
||||
const radarColors = ["#294bdb", "#159447", "#d24a3b", "#8a4fd2", "#b47400", "#008c95"] as const
|
||||
const codingBenchmarkPattern = /(swe|aider|code|coding|nl2repo)/
|
||||
const reasoningBenchmarkPattern = /(gpqa|humanity|last exam|reasoning|aime|hmmt|math|mmlu|mrcr|charxiv|cti realm)/
|
||||
const toolUseBenchmarkPattern = /(terminal bench|claw eval|tau ?(?:bench|2|3))/
|
||||
|
||||
export type ComparisonRadarModel = {
|
||||
name: string
|
||||
labName: string
|
||||
catalog: ModelCatalogEntry | null
|
||||
}
|
||||
|
||||
type ComparisonRadarProps = {
|
||||
models: readonly ComparisonRadarModel[]
|
||||
catalogModels: readonly ModelCatalogEntry[]
|
||||
}
|
||||
|
||||
type RadarAxis = {
|
||||
label: string
|
||||
description: string
|
||||
score: (model: ModelCatalogEntry) => number | undefined
|
||||
}
|
||||
|
||||
type RadarPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export function ComparisonRadar(props: ComparisonRadarProps) {
|
||||
const [activeAxis, setActiveAxis] = createSignal<number>()
|
||||
const axes = createMemo(() => buildRadarAxes(props.catalogModels))
|
||||
const series = createMemo(() =>
|
||||
props.models.map((model, index) => ({
|
||||
name: model.name,
|
||||
labName: model.labName,
|
||||
color: radarColors[index % radarColors.length],
|
||||
scores: axes().map((axis) => (model.catalog ? axis.score(model.catalog) : undefined)),
|
||||
})),
|
||||
)
|
||||
const accessibleDescription = createMemo(() =>
|
||||
series()
|
||||
.map(
|
||||
(model) =>
|
||||
`${model.name}: ${axes()
|
||||
.map((axis, index) => `${axis.label} ${formatRadarScore(model.scores[index])}`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
.join(". "),
|
||||
)
|
||||
const clearActiveAxis = (index: number) => setActiveAxis((active) => (active === index ? undefined : active))
|
||||
|
||||
return (
|
||||
<section data-section="compare-radar" aria-label="Model capabilities">
|
||||
<ol data-slot="compare-radar-legend">
|
||||
<For each={series()}>
|
||||
{(model) => (
|
||||
<li>
|
||||
<i style={{ background: model.color }} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{model.name}</strong>
|
||||
<small>{model.labName}</small>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
<div data-slot="compare-radar-chart" role="img" aria-label={accessibleDescription()}>
|
||||
<div data-slot="compare-radar-plot" aria-hidden="true">
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
|
||||
<g data-slot="compare-radar-grid">
|
||||
<For each={Array.from({ length: radarRingCount })}>
|
||||
{(_, index) => (
|
||||
<polygon points={radarPolygonPoints(axes().length, ((index() + 1) / radarRingCount) * 100)} />
|
||||
)}
|
||||
</For>
|
||||
<For each={axes()}>
|
||||
{(_, index) => {
|
||||
const point = () => radarPoint(index(), axes().length, 100)
|
||||
return <line x1="50" y1="50" x2={point().x} y2={point().y} />
|
||||
}}
|
||||
</For>
|
||||
</g>
|
||||
<For each={series()}>
|
||||
{(model) => (
|
||||
<g data-slot="compare-radar-series" style={{ color: model.color }}>
|
||||
<Show when={radarSeriesPolygon(model.scores)}>
|
||||
{(points) => <polygon data-slot="compare-radar-area" points={points()} />}
|
||||
</Show>
|
||||
<Show when={!radarSeriesPolygon(model.scores)}>
|
||||
<For each={radarSeriesConnections(model.scores)}>
|
||||
{(connection) => (
|
||||
<line
|
||||
data-slot="compare-radar-line"
|
||||
x1={connection.start.x}
|
||||
y1={connection.start.y}
|
||||
x2={connection.end.x}
|
||||
y2={connection.end.y}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<For each={model.scores}>
|
||||
{(score, index) => {
|
||||
if (score === undefined) return null
|
||||
const point = () => radarPoint(index(), axes().length, score)
|
||||
return (
|
||||
<>
|
||||
<circle data-slot="compare-radar-point" cx={point().x} cy={point().y} r="0.95" />
|
||||
<circle
|
||||
data-slot="compare-radar-point-hit"
|
||||
cx={point().x}
|
||||
cy={point().y}
|
||||
r="3"
|
||||
onMouseEnter={() => setActiveAxis(index())}
|
||||
onMouseLeave={() => clearActiveAxis(index())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</g>
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
</div>
|
||||
<For each={axes()}>
|
||||
{(axis, index) => (
|
||||
<span
|
||||
data-slot="compare-radar-axis"
|
||||
data-active={activeAxis() === index() ? "true" : undefined}
|
||||
style={radarAxisStyle(index(), axes().length)}
|
||||
tabIndex="0"
|
||||
aria-label={`${axis.label}. ${axis.description}`}
|
||||
onMouseEnter={() => setActiveAxis(index())}
|
||||
onMouseLeave={() => clearActiveAxis(index())}
|
||||
onFocus={() => setActiveAxis(index())}
|
||||
onBlur={() => clearActiveAxis(index())}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") event.currentTarget.blur()
|
||||
}}
|
||||
>
|
||||
<span data-slot="compare-radar-axis-label">{axis.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={activeAxis() !== undefined}>
|
||||
<div
|
||||
data-slot="compare-radar-tooltip"
|
||||
role="tooltip"
|
||||
style={radarTooltipStyle(activeAxis() ?? 0, axes().length)}
|
||||
>
|
||||
<strong>{axes()[activeAxis() ?? 0]?.label}</strong>
|
||||
<p>{axes()[activeAxis() ?? 0]?.description}</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="compare-radar-data">
|
||||
<table>
|
||||
<caption>Normalized model capability scores</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<For each={axes()}>{(axis) => <th>{axis.label}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={series()}>
|
||||
{(model) => (
|
||||
<tr>
|
||||
<th>{model.name}</th>
|
||||
<For each={model.scores}>{(score) => <td>{formatRadarScore(score)}</td>}</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function buildRadarAxes(catalogModels: readonly ModelCatalogEntry[]): RadarAxis[] {
|
||||
const benchmarks = benchmarkScoreGroups(catalogModels)
|
||||
const toolUseBenchmarks = benchmarkScoreGroups(catalogModels, true)
|
||||
const costs = catalogModels.flatMap((model) => {
|
||||
const cost = modelCost(model)
|
||||
return cost === undefined ? [] : [cost]
|
||||
})
|
||||
const contexts = catalogModels.flatMap((model) => (model.limit?.context === undefined ? [] : [model.limit.context]))
|
||||
const multimodalMaximum = Math.max(...catalogModels.map(multimodalFeatureCount), 0)
|
||||
|
||||
// Speed and safety stay out until the catalog exposes comparable values for them.
|
||||
return [
|
||||
{
|
||||
label: "Reasoning",
|
||||
description: "Ability to solve complex, multi-step problems. Based on reasoning benchmarks when available.",
|
||||
score: (model) =>
|
||||
benchmarkPercentile(model, benchmarks, reasoningBenchmarkPattern) ?? (model.reasoning ? 100 : 0),
|
||||
},
|
||||
{
|
||||
label: "Coding",
|
||||
description: "Performance on software engineering and coding benchmarks.",
|
||||
score: (model) => benchmarkPercentile(model, benchmarks, codingBenchmarkPattern),
|
||||
},
|
||||
{
|
||||
label: "Cost efficiency",
|
||||
description: "Relative input and output pricing. Lower-cost models score higher.",
|
||||
score: (model) => {
|
||||
const cost = modelCost(model)
|
||||
if (cost === undefined) return
|
||||
if (cost === 0) return 100
|
||||
return percentileScore(cost, costs, "lower")
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Context window",
|
||||
description: "How much input the model can process at once. Larger context windows score higher.",
|
||||
score: (model) => {
|
||||
const context = model.limit?.context
|
||||
if (context === undefined) return
|
||||
return percentileScore(context, contexts, "higher")
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Multimodal",
|
||||
description: "Support for non-text input and output, including images, audio, and video.",
|
||||
score: (model) => {
|
||||
if (multimodalMaximum === 0) return
|
||||
return (multimodalFeatureCount(model) / multimodalMaximum) * 100
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Tool use",
|
||||
description: "Performance on agent benchmarks including Terminal-Bench, Tau3, and Claw-Eval.",
|
||||
score: (model) =>
|
||||
benchmarkPercentile(model, toolUseBenchmarks, toolUseBenchmarkPattern, {
|
||||
aggregate: "average",
|
||||
includeHarness: true,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function benchmarkScoreGroups(catalogModels: readonly ModelCatalogEntry[], includeHarness = false) {
|
||||
return catalogModels.reduce<Map<string, number[]>>((groups, model) => {
|
||||
model.benchmarks
|
||||
.reduce<Map<string, number>>((scores, benchmark) => {
|
||||
const key = benchmarkKey(benchmark, includeHarness)
|
||||
scores.set(key, Math.max(scores.get(key) ?? -Infinity, benchmark.score))
|
||||
return scores
|
||||
}, new Map())
|
||||
.forEach((score, key) => {
|
||||
groups.set(key, [...(groups.get(key) ?? []), score])
|
||||
})
|
||||
return groups
|
||||
}, new Map())
|
||||
}
|
||||
|
||||
function benchmarkKey(benchmark: ModelCatalogBenchmark, includeHarness: boolean) {
|
||||
const name = normalizeBenchmarkName(benchmark.name)
|
||||
const version = normalizeBenchmarkName(benchmark.version ?? "")
|
||||
const versioned = version && !name.endsWith(version) ? `${name} ${version}` : name
|
||||
if (!includeHarness) return versioned
|
||||
const harness = normalizeBenchmarkName(benchmark.harness ?? benchmark.variant ?? "")
|
||||
return harness ? `${versioned} | ${harness}` : versioned
|
||||
}
|
||||
|
||||
function benchmarkPercentile(
|
||||
model: ModelCatalogEntry,
|
||||
benchmarks: Map<string, number[]>,
|
||||
pattern: RegExp,
|
||||
options?: { aggregate?: "average" | "best"; includeHarness?: boolean },
|
||||
) {
|
||||
const scores = Object.entries(
|
||||
model.benchmarks.reduce<Record<string, number>>((result, benchmark) => {
|
||||
const key = benchmarkKey(benchmark, options?.includeHarness ?? false)
|
||||
if (!pattern.test(key)) return result
|
||||
result[key] = Math.max(result[key] ?? -Infinity, benchmark.score)
|
||||
return result
|
||||
}, {}),
|
||||
).flatMap(([key, score]) => {
|
||||
const values = benchmarks.get(key)
|
||||
const percentile = values ? percentileScore(score, values, "higher") : undefined
|
||||
return percentile === undefined ? [] : [percentile]
|
||||
})
|
||||
if (scores.length === 0) return
|
||||
if (options?.aggregate === "average") return scores.reduce((sum, score) => sum + score, 0) / scores.length
|
||||
// Benchmark coverage varies by model, so additional published results should not lower a model's score.
|
||||
return Math.max(...scores)
|
||||
}
|
||||
|
||||
function normalizeBenchmarkName(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/\u03c4/g, "tau")
|
||||
.replace(/\u00b2/g, "2")
|
||||
.replace(/\u00b3/g, "3")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function modelCost(model: ModelCatalogEntry) {
|
||||
if (!model.cost) return
|
||||
return model.cost.input + model.cost.output
|
||||
}
|
||||
|
||||
function multimodalFeatureCount(model: ModelCatalogEntry) {
|
||||
return new Set([
|
||||
...model.modalities.input.filter((modality) => modality !== "text").map((modality) => `input:${modality}`),
|
||||
...model.modalities.output.filter((modality) => modality !== "text").map((modality) => `output:${modality}`),
|
||||
...(model.attachment ? ["attachment"] : []),
|
||||
]).size
|
||||
}
|
||||
|
||||
function percentileScore(value: number, values: number[], direction: "higher" | "lower") {
|
||||
const finite = values.filter(Number.isFinite)
|
||||
if (!Number.isFinite(value) || finite.length < 2) return
|
||||
const below = finite.filter((candidate) => candidate < value).length
|
||||
const equal = finite.filter((candidate) => candidate === value).length
|
||||
const percentile = ((below + (equal - 1) / 2) / (finite.length - 1)) * 100
|
||||
return direction === "higher" ? percentile : 100 - percentile
|
||||
}
|
||||
|
||||
function radarPoint(index: number, count: number, score: number): RadarPoint {
|
||||
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
|
||||
const radius = Math.max(0, Math.min(100, score)) / 2
|
||||
return {
|
||||
x: roundRadarCoordinate(50 + Math.cos(angle) * radius),
|
||||
y: roundRadarCoordinate(50 + Math.sin(angle) * radius),
|
||||
}
|
||||
}
|
||||
|
||||
function radarPolygonPoints(count: number, score: number) {
|
||||
return Array.from({ length: count })
|
||||
.map((_, index) => radarPoint(index, count, score))
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function radarSeriesPolygon(scores: (number | undefined)[]) {
|
||||
if (scores.some((score) => score === undefined)) return
|
||||
return scores
|
||||
.map((score, index) => radarPoint(index, scores.length, score ?? 0))
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function radarSeriesConnections(scores: (number | undefined)[]) {
|
||||
return scores.flatMap((score, index) => {
|
||||
const nextIndex = (index + 1) % scores.length
|
||||
const next = scores[nextIndex]
|
||||
if (score === undefined || next === undefined) return []
|
||||
return [
|
||||
{
|
||||
start: radarPoint(index, scores.length, score),
|
||||
end: radarPoint(nextIndex, scores.length, next),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function radarAxisStyle(index: number, count: number) {
|
||||
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
|
||||
const horizontal = Math.cos(angle)
|
||||
return {
|
||||
"--compare-radar-axis-x": `${roundRadarCoordinate(50 + horizontal * 42)}%`,
|
||||
"--compare-radar-axis-mobile-x": `${roundRadarCoordinate(50 + horizontal * 36)}%`,
|
||||
"--compare-radar-axis-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
|
||||
"--compare-radar-axis-translate-x": horizontal > 0.25 ? "0%" : horizontal < -0.25 ? "-100%" : "-50%",
|
||||
} as JSX.CSSProperties
|
||||
}
|
||||
|
||||
function radarTooltipStyle(index: number, count: number) {
|
||||
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
|
||||
return {
|
||||
"--compare-radar-tooltip-x": `${roundRadarCoordinate(50 + Math.cos(angle) * 42)}%`,
|
||||
"--compare-radar-tooltip-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
|
||||
"--compare-radar-tooltip-translate-y": Math.sin(angle) < -0.9 ? "20px" : "calc(-100% - 12px)",
|
||||
} as JSX.CSSProperties
|
||||
}
|
||||
|
||||
function roundRadarCoordinate(value: number) {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
function formatRadarScore(score: number | undefined) {
|
||||
return score === undefined ? "No data" : `${Math.round(score)}/100`
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { createAsync, useParams } from "@solidjs/router"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import ModelCompareDetailPage from "../../../component/model-compare-detail"
|
||||
import { resolveComparisonFamily } from "../../../lib/comparison-pages"
|
||||
import { getModelCatalog } from "../../model-catalog"
|
||||
|
||||
export default function ModelCompareFamily() {
|
||||
const params = useParams()
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const comparison = createMemo(() => {
|
||||
const source = catalog()
|
||||
if (!source) return undefined
|
||||
const first = resolveComparisonFamily(source, params.firstFamily ?? "")
|
||||
const second = resolveComparisonFamily(source, params.secondFamily ?? "")
|
||||
if (!first || !second || first.slug === second.slug) return null
|
||||
return { first, second }
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={comparison()}
|
||||
fallback={
|
||||
<Show when={comparison() === null}>
|
||||
<Title>Model comparison not found</Title>
|
||||
<Meta name="robots" content="noindex,follow" />
|
||||
<main data-page="stats">
|
||||
<div data-component="empty-state">
|
||||
<strong>Comparison not found</strong>
|
||||
<p>Choose two model families to compare.</p>
|
||||
<a href={`${import.meta.env.BASE_URL}compare`}>Compare models</a>
|
||||
</div>
|
||||
</main>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(resolved) => (
|
||||
<ModelCompareDetailPage
|
||||
first={{ lab: resolved().first.model.lab, slug: resolved().first.model.slug }}
|
||||
second={{ lab: resolved().second.model.lab, slug: resolved().second.model.slug }}
|
||||
family={resolved()}
|
||||
catalog={catalog()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,611 +1 @@
|
|||
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<ThemePreference>("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 (
|
||||
<main data-page="stats" data-theme={themePreference()}>
|
||||
<Title>{title()}</Title>
|
||||
<Meta name="description" content={description()} />
|
||||
<Link rel="canonical" href={canonicalUrl()} />
|
||||
<Meta property="og:type" content="website" />
|
||||
<Meta property="og:site_name" content="OpenCode" />
|
||||
<Meta property="og:title" content={title()} />
|
||||
<Meta property="og:description" content={description()} />
|
||||
<Meta property="og:url" content={canonicalUrl()} />
|
||||
<Meta name="twitter:card" content="summary" />
|
||||
<Meta name="twitter:title" content={title()} />
|
||||
<Meta name="twitter:description" content={description()} />
|
||||
<script type="application/ld+json">{structuredData()}</script>
|
||||
<Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<ComparisonHero models={models()} />
|
||||
<section id="comparison" data-section="model-panel">
|
||||
<p data-slot="section-title">
|
||||
<strong>Comparison Table.</strong> <span>Compare usage, cost, limits, and features.</span>
|
||||
</p>
|
||||
<Show
|
||||
when={stats() !== undefined}
|
||||
fallback={
|
||||
<div data-component="empty-state" data-compact="true">
|
||||
<strong>Loading comparison</strong>
|
||||
<p>Loading stats for both models.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ComparisonTable models={models()} rows={rows()} />
|
||||
</Show>
|
||||
</section>
|
||||
<section id="compare-tool" data-section="model-panel" data-variant="compact">
|
||||
<p data-slot="section-title">
|
||||
<strong>Compare Another Pair.</strong> <span>Choose two models to compare.</span>
|
||||
</p>
|
||||
<Show
|
||||
when={selectorModels().length > 1}
|
||||
fallback={
|
||||
<div data-component="empty-state" data-compact="true">
|
||||
<strong>No models found</strong>
|
||||
<p>The model list could not be loaded.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ComparisonSelector
|
||||
models={selectorModels()}
|
||||
firstId={comparisonCatalogEntry(models()[0]).id}
|
||||
secondId={comparisonCatalogEntry(models()[1]).id}
|
||||
/>
|
||||
</Show>
|
||||
</section>
|
||||
<ComparisonCardsSection
|
||||
pairs={relatedPairs()}
|
||||
title="Related Model Comparisons"
|
||||
description="Other model pairs to check."
|
||||
/>
|
||||
</div>
|
||||
<Footer
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
links={compareFooterLinks}
|
||||
bridge={{ href: "#comparison", label: "COMPARE TABLE" }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) {
|
||||
return (
|
||||
<section id="overview" data-section="model-hero">
|
||||
<a data-slot="model-back-link" href={`${import.meta.env.BASE_URL}compare`}>
|
||||
Compare
|
||||
</a>
|
||||
<div data-slot="model-hero-copy">
|
||||
<h1>
|
||||
{props.models[0].name} vs {props.models[1].name}
|
||||
</h1>
|
||||
<p>Compare usage, cost, limits, and features for these two models.</p>
|
||||
</div>
|
||||
<div data-slot="model-hero-pattern" aria-hidden="true" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonTable(props: { models: readonly [ComparisonModel, ComparisonModel]; rows: ComparisonRow[] }) {
|
||||
return (
|
||||
<div data-component="comparison-table-wrap">
|
||||
<table data-component="comparison-table">
|
||||
<caption>
|
||||
{props.models[0].name} compared with {props.models[1].name}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Metric</th>
|
||||
<For each={props.models}>{(model) => <th scope="col">{model.name}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.rows}>
|
||||
{(row) => {
|
||||
const best = () => bestCellIndex(row)
|
||||
return (
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<strong>{row.label}</strong>
|
||||
<span>{row.description}</span>
|
||||
</th>
|
||||
<For each={row.cells}>
|
||||
{(cell, index) => (
|
||||
<td data-best={best() === index() ? "true" : undefined}>
|
||||
<strong>{cell.value}</strong>
|
||||
<Show when={cell.detail}>{(detail) => <span>{detail()}</span>}</Show>
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<Record<string, ModelCatalogEntry>>((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")
|
||||
}
|
||||
export { default } from "../../../../../component/model-compare-detail"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import { LocaleLinks } from "../../component/locale-links"
|
|||
import { useI18n } from "../../context/i18n"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { localizedUrl } from "../../lib/language"
|
||||
import { comparisonHref, modelRefFromCatalog, type ComparisonModelRef } from "../compare-cards"
|
||||
import {
|
||||
ComparisonCardsSection,
|
||||
comparisonHref,
|
||||
modelRefFromCatalog,
|
||||
type ComparisonModelRef,
|
||||
type ComparisonPair,
|
||||
} from "../compare-cards"
|
||||
import { formatCatalogLabName, getModelCatalog, type ModelCatalogEntry } from "../model-catalog"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
import {
|
||||
|
|
@ -56,13 +62,6 @@ const categoryTemplates = [
|
|||
},
|
||||
] as const
|
||||
|
||||
type CompareCategory = {
|
||||
title: string
|
||||
description: string
|
||||
first: ComparisonModelRef
|
||||
second: ComparisonModelRef
|
||||
avatars: ComparisonModelRef[]
|
||||
}
|
||||
type CompareSlot = "first" | "second"
|
||||
|
||||
export default function ModelCompareIndex() {
|
||||
|
|
@ -162,16 +161,12 @@ export default function ModelCompareIndex() {
|
|||
<CompareHomeSelector models={featuredModels()} />
|
||||
</Show>
|
||||
</section>
|
||||
<Show when={categories().length > 0}>
|
||||
<section id="model-comparison" data-section="compare-home-related">
|
||||
<p data-slot="section-title">
|
||||
<strong>Related comparisons.</strong> <span>Other model pairs to check.</span>
|
||||
</p>
|
||||
<div data-component="compare-home-card-grid">
|
||||
<For each={categories()}>{(category) => <CompareHomeCard category={category} />}</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
<ComparisonCardsSection
|
||||
pairs={categories()}
|
||||
title="Related comparisons"
|
||||
description="Other model pairs to check."
|
||||
variant="featured"
|
||||
/>
|
||||
</div>
|
||||
<Footer
|
||||
themePreference={themePreference()}
|
||||
|
|
@ -443,35 +438,6 @@ function HeroModelStack() {
|
|||
)
|
||||
}
|
||||
|
||||
function CompareHomeCard(props: { category: CompareCategory }) {
|
||||
return (
|
||||
<a
|
||||
data-component="compare-home-card"
|
||||
href={comparisonHref(props.category.first, props.category.second)}
|
||||
aria-label={`${props.category.title}: ${props.category.first.name} vs ${props.category.second.name}`}
|
||||
>
|
||||
<span data-slot="compare-home-card-head">
|
||||
<span>
|
||||
<strong>{props.category.title}</strong>
|
||||
<em>{props.category.description}</em>
|
||||
</span>
|
||||
<b aria-hidden="true" />
|
||||
</span>
|
||||
<span data-slot="compare-home-card-divider" aria-hidden="true" />
|
||||
<span data-slot="compare-home-card-models">
|
||||
<span>{props.category.first.name}</span>
|
||||
<i aria-hidden="true">·</i>
|
||||
<span>{props.category.second.name}</span>
|
||||
</span>
|
||||
<span data-slot="compare-home-card-avatars" aria-hidden="true">
|
||||
<For each={props.category.avatars}>
|
||||
{(model) => <LabLogo lab={model.lab} label={model.name} size="small" />}
|
||||
</For>
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelAvatar(props: { model: ModelCatalogEntry; size: "large" | "small" | "tiny" }) {
|
||||
return <LabLogo lab={props.model.lab} label={props.model.name} size={props.size} />
|
||||
}
|
||||
|
|
@ -486,8 +452,8 @@ function LabLogo(props: { lab: string; label: string; size: "large" | "small" |
|
|||
)
|
||||
}
|
||||
|
||||
function buildComparisonCategories(models: ModelCatalogEntry[]): CompareCategory[] {
|
||||
return categoryTemplates.reduce<{ keys: Set<string>; categories: CompareCategory[] }>(
|
||||
function buildComparisonCategories(models: ModelCatalogEntry[]): ComparisonPair[] {
|
||||
return categoryTemplates.reduce<{ keys: Set<string>; categories: ComparisonPair[] }>(
|
||||
(result, template, index) => {
|
||||
const candidates = categoryCandidates(template.kind, models)
|
||||
const pair = categoryPair(candidates, models, index, result.keys)
|
||||
|
|
@ -496,11 +462,10 @@ function buildComparisonCategories(models: ModelCatalogEntry[]): CompareCategory
|
|||
const first = modelRefFromCatalog(pair.first)
|
||||
const second = modelRefFromCatalog(pair.second)
|
||||
result.categories.push({
|
||||
title: template.title,
|
||||
detail: template.title,
|
||||
description: template.description,
|
||||
first,
|
||||
second,
|
||||
avatars: [first, second],
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
[data-page="stats"][data-layout="compare-detail"] {
|
||||
/* The table contains its own wide rows; keep the page itself out of the horizontal scroll chain. */
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
[data-page="stats"] section[id],
|
||||
[data-page="stats"] [data-component="leaderboard"][id] {
|
||||
scroll-margin-top: 88px;
|
||||
|
|
@ -5852,7 +5857,6 @@
|
|||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
|
|
@ -5863,32 +5867,10 @@
|
|||
box-shadow: 0 1px 1.5px #0000000f;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b::before {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
box-shadow:
|
||||
7px 0 currentColor,
|
||||
0 7px currentColor;
|
||||
color: var(--stats-muted);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b::after {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
content: "";
|
||||
background:
|
||||
radial-gradient(circle at 4px 4px, transparent 2.5px, var(--stats-muted) 2.75px 4px, transparent 4.25px),
|
||||
linear-gradient(var(--stats-muted) 0 0) 7px 8px / 5px 1.5px no-repeat;
|
||||
transform: rotate(45deg);
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-divider"] {
|
||||
|
|
@ -5923,6 +5905,858 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-detail-hero"] {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-content: end;
|
||||
gap: 24px;
|
||||
min-height: 316px;
|
||||
box-sizing: border-box;
|
||||
padding: 128px 40px 40px;
|
||||
color: var(--stats-text);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-hero-grid"] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-detail-hero"] h1 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--stats-text);
|
||||
font-size: 40px;
|
||||
font-weight: 500;
|
||||
line-height: 60px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-actions"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-action"],
|
||||
[data-page="stats"] a[data-slot="compare-detail-action"] {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 12px 0 8px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: var(--stats-text);
|
||||
background: var(--stats-bg);
|
||||
box-shadow:
|
||||
0 0 0 0.5px color-mix(in srgb, var(--stats-text) 14%, transparent),
|
||||
0 1px 1.5px color-mix(in srgb, #000000 10%, transparent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-action"]::before,
|
||||
[data-page="stats"] a[data-slot="compare-detail-action"]::before {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, rgb(255 255 255 / 7%), transparent);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-action"] > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-action"][aria-pressed] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-action"]:hover,
|
||||
[data-page="stats"] button[data-slot="compare-detail-action"]:focus-visible,
|
||||
[data-page="stats"] a[data-slot="compare-detail-action"]:hover,
|
||||
[data-page="stats"] a[data-slot="compare-detail-action"]:focus-visible {
|
||||
background: var(--stats-layer);
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-action"]:disabled {
|
||||
color: var(--stats-muted);
|
||||
background: var(--stats-bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-action"][data-active="true"] {
|
||||
color: var(--stats-text);
|
||||
background: var(--stats-bg);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-highlight-icon"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
width: 31px;
|
||||
height: 16px;
|
||||
box-sizing: border-box;
|
||||
padding: 1px;
|
||||
overflow: hidden;
|
||||
background: var(--stats-line-strong);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-highlight-icon"] i {
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-page="stats"]
|
||||
[data-slot="compare-detail-action"][aria-pressed="true"]
|
||||
[data-slot="compare-detail-highlight-icon"] {
|
||||
background: var(--stats-accent);
|
||||
}
|
||||
|
||||
[data-page="stats"]
|
||||
[data-slot="compare-detail-action"][aria-pressed="false"]
|
||||
[data-slot="compare-detail-highlight-icon"]
|
||||
i:first-child,
|
||||
[data-page="stats"]
|
||||
[data-slot="compare-detail-action"][aria-pressed="true"]
|
||||
[data-slot="compare-detail-highlight-icon"]
|
||||
i:last-child {
|
||||
background: #fafafa;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgb(0 0 0 / 12%),
|
||||
0 1px 2px -1px rgb(0 0 0 / 8%),
|
||||
0 2px 4px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-action"] [data-slot="compare-home-plus"] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-radar"] {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 800px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 800px;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid var(--stats-line);
|
||||
border-left: 1px solid var(--stats-line);
|
||||
color: var(--stats-text);
|
||||
background: var(--stats-bg);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] li {
|
||||
display: grid;
|
||||
grid-template-columns: 6px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] li > i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] li > span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] strong,
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] small {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] strong {
|
||||
color: var(--stats-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] small {
|
||||
color: var(--stats-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-chart"] {
|
||||
position: relative;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-plot"] {
|
||||
position: absolute;
|
||||
inset: 17.5%;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-plot"] svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-grid"] polygon,
|
||||
[data-page="stats"] [data-slot="compare-radar-grid"] line {
|
||||
fill: none;
|
||||
stroke: var(--stats-line);
|
||||
stroke-width: 1px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-area"],
|
||||
[data-page="stats"] [data-slot="compare-radar-line"] {
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-area"] {
|
||||
fill: currentColor;
|
||||
fill-opacity: 0.09;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-line"] {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-point"] {
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-point-hit"] {
|
||||
fill: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-axis"] {
|
||||
position: absolute;
|
||||
top: var(--compare-radar-axis-y);
|
||||
left: var(--compare-radar-axis-x);
|
||||
max-width: 160px;
|
||||
color: var(--stats-text);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transform: translate(var(--compare-radar-axis-translate-x), -50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-axis"]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-axis-label"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-axis"][data-active="true"] [data-slot="compare-radar-axis-label"] {
|
||||
background: var(--stats-layer-2);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-axis"]:focus-visible [data-slot="compare-radar-axis-label"] {
|
||||
outline: 1px solid var(--stats-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-tooltip"] {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: var(--compare-radar-tooltip-y);
|
||||
left: clamp(104px, var(--compare-radar-tooltip-x), calc(100% - 104px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 192px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
color: var(--stats-text);
|
||||
background: var(--stats-layer);
|
||||
box-shadow:
|
||||
0 0 0 0.5px color-mix(in srgb, var(--stats-text) 12%, transparent),
|
||||
0 4px 8px color-mix(in srgb, #000000 8%, transparent),
|
||||
0 8px 16px color-mix(in srgb, #000000 4%, transparent);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, var(--compare-radar-tooltip-translate-y));
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-tooltip"] strong,
|
||||
[data-page="stats"] [data-slot="compare-radar-tooltip"] p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-tooltip"] strong {
|
||||
color: var(--stats-text);
|
||||
font-weight: 500;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-tooltip"] p {
|
||||
color: var(--stats-muted);
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-data"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-table"] {
|
||||
--compare-detail-label-column: 292px;
|
||||
--compare-detail-model-column-min: 360px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-heading-scroll"] {
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
z-index: 9;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: none;
|
||||
background: var(--stats-bg);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-heading-scroll"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-body-scroll"] {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-detail-selector"],
|
||||
[data-page="stats"] [data-section="compare-detail-matrix"] {
|
||||
min-width: calc(
|
||||
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min)
|
||||
);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-detail-selector"] {
|
||||
position: relative;
|
||||
height: 98px;
|
||||
min-height: 98px;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-detail-selector"]::after {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
inset: 0;
|
||||
border-top: 1px solid var(--stats-line);
|
||||
border-bottom: 1px solid var(--stats-line);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-selector-grid"] {
|
||||
display: grid;
|
||||
grid-template-columns: var(--compare-detail-grid);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="3"]
|
||||
[data-section="compare-detail-selector"],
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="3"]
|
||||
[data-section="compare-detail-matrix"] {
|
||||
min-width: calc(
|
||||
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min)
|
||||
);
|
||||
}
|
||||
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="4"]
|
||||
[data-section="compare-detail-selector"],
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="4"]
|
||||
[data-section="compare-detail-matrix"] {
|
||||
min-width: calc(
|
||||
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min)
|
||||
);
|
||||
}
|
||||
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="5"]
|
||||
[data-section="compare-detail-selector"],
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="5"]
|
||||
[data-section="compare-detail-matrix"] {
|
||||
min-width: calc(
|
||||
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min)
|
||||
);
|
||||
}
|
||||
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="6"]
|
||||
[data-section="compare-detail-selector"],
|
||||
[data-page="stats"]
|
||||
[data-component="compare-detail-table"][data-model-count="6"]
|
||||
[data-section="compare-detail-matrix"] {
|
||||
min-width: calc(
|
||||
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min) + var(--compare-detail-model-column-min) +
|
||||
var(--compare-detail-model-column-min)
|
||||
);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-selector-spacer"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid var(--stats-line);
|
||||
border-left: 1px solid var(--stats-line);
|
||||
background: var(--stats-bg);
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 40px;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
border-radius: 0;
|
||||
color: var(--stats-text);
|
||||
background: var(--stats-bg);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"][data-column]:not([data-column="0"]) {
|
||||
border-left: 1px solid var(--stats-line);
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"][data-last="true"] {
|
||||
border-right: 1px solid var(--stats-line);
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"]:hover,
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"]:focus-visible {
|
||||
background: var(--stats-layer);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-select-name"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--stats-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"] svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--stats-muted);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-detail-matrix"] {
|
||||
position: relative;
|
||||
color: var(--stats-text);
|
||||
border-bottom: 1px solid var(--stats-line);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-matrix"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-group"] {
|
||||
display: grid;
|
||||
grid-template-columns: var(--compare-detail-grid);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-group"] + [data-slot="compare-detail-group"] {
|
||||
border-top: 1px solid var(--stats-line);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-label"],
|
||||
[data-page="stats"] [data-slot="compare-detail-value"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 40px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-label"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
border-right: 1px solid var(--stats-line);
|
||||
border-left: 1px solid var(--stats-line);
|
||||
color: var(--stats-muted);
|
||||
background: var(--stats-bg);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-value"][data-column]:not([data-column="0"]) {
|
||||
border-left: 1px solid var(--stats-line);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-value"][data-last="true"] {
|
||||
border-right: 1px solid var(--stats-line);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-label"][data-spacer="true"],
|
||||
[data-page="stats"] [data-slot="compare-detail-value"][data-spacer="true"] {
|
||||
min-height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] {
|
||||
gap: 12px;
|
||||
color: var(--stats-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
color: var(--stats-muted);
|
||||
background: var(--stats-layer-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-value"] {
|
||||
justify-content: flex-end;
|
||||
color: var(--stats-text);
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-value"][data-best="true"] {
|
||||
background: color-mix(in srgb, #198b43 8%, transparent);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-value-main"],
|
||||
[data-page="stats"] [data-slot="compare-detail-value-link"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-value-link"] {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, var(--stats-text) 30%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-unit"] {
|
||||
color: var(--stats-muted);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-trend"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 5px;
|
||||
color: var(--stats-muted);
|
||||
background: var(--stats-layer-2);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-trend"][data-trend="up"] {
|
||||
color: #198b43;
|
||||
background: #e2f8e9;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-trend"][data-trend="down"] {
|
||||
color: #c93737;
|
||||
background: #fae8e8;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-boolean"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-boolean"][data-value="true"] {
|
||||
color: #198b43;
|
||||
background: #e2f8e9;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-boolean"][data-value="false"] {
|
||||
color: #c93737;
|
||||
background: #fae8e8;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-value"][data-chart="true"] {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 12px;
|
||||
min-height: 102px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-bars"] {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-bars"] i {
|
||||
flex: 1 1 0;
|
||||
min-width: 2px;
|
||||
max-width: 6px;
|
||||
background: color-mix(in srgb, var(--stats-text) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-bar-dates"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--stats-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-no-chart"] {
|
||||
justify-self: end;
|
||||
color: var(--stats-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 80rem) {
|
||||
[data-page="stats"] [data-section="compare-detail-hero"] {
|
||||
min-height: 280px;
|
||||
padding: 104px 32px 40px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-hero-grid"] {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-actions"] {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-radar"] {
|
||||
grid-template-columns: minmax(180px, 208px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] {
|
||||
padding-right: 24px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-chart"] {
|
||||
align-self: center;
|
||||
width: min(100%, 800px);
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-table"] {
|
||||
--compare-detail-label-column: 220px;
|
||||
--compare-detail-model-column-min: 320px;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"],
|
||||
[data-page="stats"] [data-slot="compare-detail-label"],
|
||||
[data-page="stats"] [data-slot="compare-detail-value"] {
|
||||
padding-right: 32px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
[data-page="stats"] [data-section="compare-detail-hero"] {
|
||||
gap: 20px;
|
||||
min-height: 316px;
|
||||
padding: 72px 24px 40px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-detail-hero"] h1 {
|
||||
gap: 12px;
|
||||
font-size: 32px;
|
||||
line-height: 42px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-actions"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-radar"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
padding: 32px 24px 24px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 32px;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] li {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-chart"] {
|
||||
justify-self: center;
|
||||
width: min(100%, 720px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
[data-page="stats"] [data-section="compare-detail-hero"] h1 {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-detail-action"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-radar"] {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-legend"] li {
|
||||
flex-basis: 140px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-axis"] {
|
||||
left: var(--compare-radar-axis-mobile-x);
|
||||
max-width: 104px;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-axis-label"] {
|
||||
padding-right: 6px;
|
||||
padding-left: 6px;
|
||||
margin-right: -6px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-table"] {
|
||||
--compare-detail-label-column: 188px;
|
||||
--compare-detail-model-column-min: 246px;
|
||||
}
|
||||
|
||||
[data-page="stats"] button[data-slot="compare-detail-select-model"],
|
||||
[data-page="stats"] [data-slot="compare-detail-label"],
|
||||
[data-page="stats"] [data-slot="compare-detail-value"] {
|
||||
padding-right: 24px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-model-modal-scrim"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export default function StatsHome() {
|
|||
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
|
||||
title="Model Comparisons"
|
||||
description="Popular model pairs from the leaderboard."
|
||||
variant="featured"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -56,14 +56,18 @@ export type ModelCatalog = {
|
|||
labs: ModelCatalogLab[]
|
||||
}
|
||||
|
||||
export const getModelCatalog = query(async () => {
|
||||
"use server"
|
||||
export async function loadModelCatalog() {
|
||||
const [models, pricing, labs] = await Promise.all([
|
||||
fetchCatalogPayload(modelCatalogSourceUrl),
|
||||
fetchCatalogPayload(modelCatalogPricingUrl),
|
||||
fetchLabCatalogPayload(modelCatalogLabSourceUrl),
|
||||
])
|
||||
return buildModelCatalog(models, pricing, labs)
|
||||
}
|
||||
|
||||
export const getModelCatalog = query(async () => {
|
||||
"use server"
|
||||
return loadModelCatalog()
|
||||
}, "getModelCatalog")
|
||||
|
||||
export function findModelCatalogEntry(catalog: ModelCatalog, model: string, lab?: string) {
|
||||
|
|
|
|||
113
packages/stats/app/src/routes/sitemap.xml.ts
Normal file
113
packages/stats/app/src/routes/sitemap.xml.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { getStatsHomeData } from "@opencode-ai/stats-core/domain/home"
|
||||
import { runtime } from "@opencode-ai/stats-core/runtime"
|
||||
import {
|
||||
canonicalFamilyComparisonPath,
|
||||
canonicalModelComparisonPath,
|
||||
comparisonFamilies,
|
||||
comparisonSitemapModels,
|
||||
latestFamilyComparisonPath,
|
||||
resolveComparisonFamily,
|
||||
} from "../lib/comparison-pages"
|
||||
import { baseUrl } from "../lib/language"
|
||||
import { loadModelCatalog } from "./model-catalog"
|
||||
|
||||
type SitemapEntry = {
|
||||
path: string
|
||||
lastmod?: string
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [catalog, stats] = await Promise.all([
|
||||
loadModelCatalog(),
|
||||
runtime.runPromise(getStatsHomeData()).catch(() => undefined),
|
||||
])
|
||||
const lastmod = sitemapDate(
|
||||
stats?.updatedAt,
|
||||
...catalog.models.map((model) => model.lastUpdated ?? model.releaseDate),
|
||||
)
|
||||
const families = comparisonFamilies.flatMap((family) => {
|
||||
const resolved = resolveComparisonFamily(catalog, family.slug)
|
||||
return resolved ? [resolved] : []
|
||||
})
|
||||
const familyComparisons = families.flatMap((first, index) =>
|
||||
families.slice(index + 1).map((second) => ({
|
||||
path: canonicalFamilyComparisonPath(first, second),
|
||||
lastmod: sitemapDate(
|
||||
stats?.updatedAt,
|
||||
first.model.lastUpdated ?? first.model.releaseDate,
|
||||
second.model.lastUpdated ?? second.model.releaseDate,
|
||||
),
|
||||
})),
|
||||
)
|
||||
const models = comparisonSitemapModels(catalog, stats?.leaderboard["All Users"]["2M"])
|
||||
const modelComparisons = models.flatMap((first, index) =>
|
||||
models.slice(index + 1).flatMap((second) => {
|
||||
if (latestFamilyComparisonPath(catalog, first, second)) return []
|
||||
return [
|
||||
{
|
||||
path: canonicalModelComparisonPath(first, second),
|
||||
lastmod: sitemapDate(
|
||||
stats?.updatedAt,
|
||||
first.lastUpdated ?? first.releaseDate,
|
||||
second.lastUpdated ?? second.releaseDate,
|
||||
),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
const entries = uniqueSitemapEntries([{ path: "/data/compare", lastmod }, ...familyComparisons, ...modelComparisons])
|
||||
|
||||
return new Response(sitemapXml(entries), {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400",
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueSitemapEntries(entries: SitemapEntry[]) {
|
||||
return Object.values(
|
||||
entries.reduce<Record<string, SitemapEntry>>((result, entry) => {
|
||||
result[entry.path] = entry
|
||||
return result
|
||||
}, {}),
|
||||
).toSorted((a, b) => a.path.localeCompare(b.path))
|
||||
}
|
||||
|
||||
function sitemapXml(entries: SitemapEntry[]) {
|
||||
const urls = entries
|
||||
.map(
|
||||
(entry) => ` <url>
|
||||
<loc>${escapeXml(new URL(entry.path, baseUrl).toString())}</loc>${
|
||||
entry.lastmod
|
||||
? `
|
||||
<lastmod>${entry.lastmod}</lastmod>`
|
||||
: ""
|
||||
}
|
||||
</url>`,
|
||||
)
|
||||
.join("\n")
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>`
|
||||
}
|
||||
|
||||
function sitemapDate(...values: (string | undefined | null)[]) {
|
||||
const dates = values.flatMap((value) => {
|
||||
if (!value) return []
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? [] : [date]
|
||||
})
|
||||
if (dates.length === 0) return undefined
|
||||
return new Date(Math.min(Date.now(), Math.max(...dates.map((date) => date.getTime())))).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
}
|
||||
|
|
@ -113,32 +113,32 @@ const poll: (
|
|||
return yield* poll(client, queryExecutionId, attempt + 1)
|
||||
})
|
||||
|
||||
const results: (
|
||||
client: AwsAthenaClient,
|
||||
queryExecutionId: string,
|
||||
nextToken?: string,
|
||||
) => Effect.Effect<AthenaData[], AthenaQueryError> = Effect.fn("Athena.results")(function* (
|
||||
client: AwsAthenaClient,
|
||||
queryExecutionId: string,
|
||||
nextToken?: string,
|
||||
) {
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.send(
|
||||
new GetQueryResultsCommand({
|
||||
QueryExecutionId: queryExecutionId,
|
||||
NextToken: nextToken,
|
||||
MaxResults: ATHENA_PAGE_SIZE,
|
||||
}),
|
||||
),
|
||||
catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }),
|
||||
const results: (client: AwsAthenaClient, queryExecutionId: string) => Effect.Effect<AthenaData[], AthenaQueryError> =
|
||||
Effect.fn("Athena.results")(function* (client: AwsAthenaClient, queryExecutionId: string) {
|
||||
// Accumulate pages iteratively; recursive spreads copied every previously
|
||||
// fetched row per page and blew up memory on large result sets.
|
||||
const rows: AthenaData[] = []
|
||||
let nextToken: string | undefined
|
||||
while (true) {
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.send(
|
||||
new GetQueryResultsCommand({
|
||||
QueryExecutionId: queryExecutionId,
|
||||
NextToken: nextToken,
|
||||
MaxResults: ATHENA_PAGE_SIZE,
|
||||
}),
|
||||
),
|
||||
catch: (cause) =>
|
||||
new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }),
|
||||
})
|
||||
const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? []
|
||||
// The first page starts with the header row.
|
||||
for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row))
|
||||
if (!result.NextToken) return rows
|
||||
nextToken = result.NextToken
|
||||
}
|
||||
})
|
||||
const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? []
|
||||
const rows = (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1).map((row) => rowData(columns, row))
|
||||
|
||||
if (!result.NextToken) return rows
|
||||
return [...rows, ...(yield* results(client, queryExecutionId, result.NextToken))]
|
||||
})
|
||||
|
||||
function rowData(columns: string[], row: Row): AthenaData {
|
||||
return Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -94,10 +94,15 @@ export type StatsModelComparisonEntry = {
|
|||
tokenShare: number
|
||||
tokenChange: number
|
||||
totals: StatsModelData["totals"]
|
||||
usage: ModelUsagePoint[]
|
||||
}
|
||||
export type StatsModelComparisonInput = {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
export type StatsModelComparisonData = {
|
||||
updatedAt: string | null
|
||||
models: [StatsModelComparisonEntry | null, StatsModelComparisonEntry | null]
|
||||
models: (StatsModelComparisonEntry | null)[]
|
||||
}
|
||||
export type StatsHomeData = {
|
||||
updatedAt: string | null
|
||||
|
|
@ -289,27 +294,35 @@ function dateValue(value: unknown) {
|
|||
return value instanceof Date ? value : new Date(stringValue(value))
|
||||
}
|
||||
|
||||
export const getStatsModelComparisonData: (
|
||||
firstProvider: string,
|
||||
firstModel: string,
|
||||
secondProvider: string,
|
||||
secondModel: string,
|
||||
) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelComparison.getData")(
|
||||
function* (firstProvider, firstModel, secondProvider, secondModel) {
|
||||
export const getStatsModelsComparisonData: (
|
||||
models: readonly StatsModelComparisonInput[],
|
||||
) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelsComparison.getData")(
|
||||
function* (models) {
|
||||
const modelStats = yield* ModelStatRepo
|
||||
const rows = yield* modelStats.listDaily()
|
||||
const first = toComparisonEntry(buildStatsModelData(firstModel, rows, [], firstProvider))
|
||||
const second = toComparisonEntry(buildStatsModelData(secondModel, rows, [], secondProvider))
|
||||
const latest = [first?.updatedAt, second?.updatedAt]
|
||||
const entries = models.map((model) => toComparisonEntry(buildStatsModelData(model.model, rows, [], model.provider)))
|
||||
const latest = entries
|
||||
.map((model) => model?.updatedAt)
|
||||
.flatMap((value) => (value ? [dateTime(value)] : []))
|
||||
.toSorted((a, b) => b - a)[0]
|
||||
return {
|
||||
updatedAt: latest === undefined ? null : new Date(latest).toISOString(),
|
||||
models: [first, second],
|
||||
models: entries,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const getStatsModelComparisonData = (
|
||||
firstProvider: string,
|
||||
firstModel: string,
|
||||
secondProvider: string,
|
||||
secondModel: string,
|
||||
) =>
|
||||
getStatsModelsComparisonData([
|
||||
{ provider: firstProvider, model: firstModel },
|
||||
{ provider: secondProvider, model: secondModel },
|
||||
])
|
||||
|
||||
function buildStatsHomeData(
|
||||
modelRows: ModelStatMetric[],
|
||||
providerRows: ProviderStatMetric[],
|
||||
|
|
@ -501,6 +514,7 @@ function toComparisonEntry(data: StatsModelData | null): StatsModelComparisonEnt
|
|||
tokenShare: data.tokenShare,
|
||||
tokenChange: data.tokenChange,
|
||||
totals: data.totals,
|
||||
usage: data.usage,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
|
|||
|
||||
export type StatDimension = "model" | "provider" | "geo" | "geo_model"
|
||||
|
||||
export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) {
|
||||
// All stat dimensions and both grains are computed in one query via GROUPING SETS so
|
||||
// the source table is scanned once per sync pass; separate queries per dimension (and
|
||||
// the previous weekly/daily UNION ALL) each re-scanned the same events.
|
||||
export function buildStatsQuery(periodStart: Date, periodEnd: Date) {
|
||||
const periodStartValue = sqlString(periodStart.toISOString())
|
||||
const periodEndValue = sqlString(periodEnd.toISOString())
|
||||
const periodStartDateValue = sqlString(periodStart.toISOString().slice(0, 10))
|
||||
|
|
@ -22,23 +25,6 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
|
|||
const sourceTable = [Resource.InferenceEvent.catalog, Resource.InferenceEvent.database, Resource.InferenceEvent.table]
|
||||
.map(sqlIdentifier)
|
||||
.join(".")
|
||||
const dimensionSql = (() => {
|
||||
if (dimension === "model")
|
||||
return {
|
||||
select: "provider, model, COALESCE(MAX(NULLIF(provider_model, '')), '') AS provider_model",
|
||||
groupBy: "provider, model",
|
||||
}
|
||||
if (dimension === "provider") return { select: "provider", groupBy: "provider" }
|
||||
if (dimension === "geo_model")
|
||||
return {
|
||||
select: "provider, model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
|
||||
groupBy: "provider, model, country",
|
||||
}
|
||||
return {
|
||||
select: "'all' AS provider, 'all' AS model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
|
||||
groupBy: "country",
|
||||
}
|
||||
})()
|
||||
const aggregateColumns = `
|
||||
COUNT(DISTINCT session) AS sessions,
|
||||
COUNT(*) AS requests,
|
||||
|
|
@ -135,34 +121,41 @@ WITH normalized AS (
|
|||
COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents
|
||||
FROM normalized
|
||||
WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")})
|
||||
), weekly AS (
|
||||
), periods AS (
|
||||
SELECT
|
||||
concat(CAST(year_of_week(event_time) AS varchar), '-W', lpad(CAST(week(event_time) AS varchar), 2, '0')) AS week_key,
|
||||
substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key,
|
||||
*
|
||||
FROM filtered
|
||||
), daily AS (
|
||||
SELECT substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key, *
|
||||
FROM filtered
|
||||
)
|
||||
SELECT
|
||||
'week' AS grain,
|
||||
week_key AS period_key,
|
||||
CASE WHEN grouping(week_key) = 0 THEN 'week' ELSE 'day' END AS grain,
|
||||
COALESCE(week_key, day_key) AS period_key,
|
||||
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
|
||||
CASE
|
||||
WHEN grouping(country) = 0 AND grouping(model) = 0 THEN 'geo_model'
|
||||
WHEN grouping(country) = 0 THEN 'geo'
|
||||
WHEN grouping(model) = 0 THEN 'model'
|
||||
ELSE 'provider'
|
||||
END AS dimension,
|
||||
tier,
|
||||
${dimensionSql.select},
|
||||
CASE WHEN grouping(provider) = 0 THEN provider ELSE 'all' END AS provider,
|
||||
CASE WHEN grouping(model) = 0 THEN model WHEN grouping(country) = 0 THEN 'all' END AS model,
|
||||
CASE WHEN grouping(model) = 0 AND grouping(country) = 1 THEN COALESCE(MAX(NULLIF(provider_model, '')), '') END AS provider_model,
|
||||
CASE WHEN grouping(country) = 0 THEN country END AS country,
|
||||
CASE WHEN grouping(country) = 0 THEN COALESCE(MAX(NULLIF(continent, '')), '') END AS continent,
|
||||
${aggregateColumns}
|
||||
FROM weekly
|
||||
GROUP BY week_key, tier, ${dimensionSql.groupBy}
|
||||
UNION ALL
|
||||
SELECT
|
||||
'day' AS grain,
|
||||
day_key AS period_key,
|
||||
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
|
||||
tier,
|
||||
${dimensionSql.select},
|
||||
${aggregateColumns}
|
||||
FROM daily
|
||||
GROUP BY day_key, tier, ${dimensionSql.groupBy}
|
||||
FROM periods
|
||||
GROUP BY GROUPING SETS (
|
||||
(week_key, tier, provider, model),
|
||||
(week_key, tier, provider),
|
||||
(week_key, tier, country),
|
||||
(week_key, tier, provider, model, country),
|
||||
(day_key, tier, provider, model),
|
||||
(day_key, tier, provider),
|
||||
(day_key, tier, country),
|
||||
(day_key, tier, provider, model, country)
|
||||
)
|
||||
ORDER BY grain, period_key, total_tokens DESC
|
||||
`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, asc, eq, inArray, or } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray, max, or } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { DatabaseError, DrizzleClient } from "../database"
|
||||
|
|
@ -43,6 +43,7 @@ export type ModelStatMetric = {
|
|||
export declare namespace ModelStatRepo {
|
||||
export interface Service {
|
||||
readonly listDaily: () => Effect.Effect<ModelStatMetric[], DatabaseError>
|
||||
readonly lastSyncedAt: () => Effect.Effect<Date | null, DatabaseError>
|
||||
readonly upsert: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError>
|
||||
readonly deleteRetiredDimensions: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
|
|
@ -111,6 +112,14 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
|
|||
})
|
||||
})
|
||||
|
||||
const lastSyncedAt = Effect.fn("ModelStatRepo.lastSyncedAt")(function* () {
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => db.select({ value: max(modelStat.updated_at) }).from(modelStat),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
return result[0]?.value ?? null
|
||||
})
|
||||
|
||||
const upsert = Effect.fn("ModelStatRepo.upsert")(function* (rows: ModelStatRow[]) {
|
||||
yield* Effect.forEach(
|
||||
chunks(rows, UPSERT_CHUNK_SIZE),
|
||||
|
|
@ -192,7 +201,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
|
|||
})
|
||||
})
|
||||
|
||||
return ModelStatRepo.of({ listDaily, upsert, deleteRetiredDimensions })
|
||||
return ModelStatRepo.of({ listDaily, lastSyncedAt, upsert, deleteRetiredDimensions })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,85 +12,90 @@ const DATALAKE_INGESTION_LAG_MS = 5 * 60_000
|
|||
const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime()
|
||||
const WEEK_MS = 7 * 86_400_000
|
||||
const DISPLAY_WINDOW_MS = 56 * 86_400_000
|
||||
// Anchor incremental passes to the ISO week containing this lookback, so the pass
|
||||
// after a week boundary still recomputes the previous week's final aggregates even
|
||||
// if the boundary pass itself failed.
|
||||
const INCREMENTAL_LOOKBACK_MS = 2 * 3_600_000
|
||||
|
||||
export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
|
||||
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
|
||||
|
||||
export const syncStats: () => Effect.Effect<
|
||||
SyncStatsResult,
|
||||
SyncStatsError,
|
||||
Athena | ModelStatRepo | ProviderStatRepo | GeoStatRepo
|
||||
> = Effect.fn("StatSync.sync")(function* () {
|
||||
const startedAt = yield* DateTime.nowAsDate
|
||||
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
|
||||
// May 27 was partial, so keep Athena stats anchored at the first complete day.
|
||||
const periodStart = new Date(
|
||||
export const syncStats: (options?: {
|
||||
full?: boolean
|
||||
}) => Effect.Effect<SyncStatsResult, SyncStatsError, Athena | ModelStatRepo | ProviderStatRepo | GeoStatRepo> =
|
||||
Effect.fn("StatSync.sync")(function* (options?: { full?: boolean }) {
|
||||
const startedAt = yield* DateTime.nowAsDate
|
||||
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
|
||||
const periodStart = options?.full ? fullPeriodStart(periodEnd) : incrementalPeriodStart(periodEnd)
|
||||
const athena = yield* Athena
|
||||
const modelStats = yield* ModelStatRepo
|
||||
const providerStats = yield* ProviderStatRepo
|
||||
const geoStats = yield* GeoStatRepo
|
||||
|
||||
yield* logRuntimeCheck()
|
||||
|
||||
const rows = yield* athena.query(buildStatsQuery(periodStart, periodEnd))
|
||||
const modelRows = modelRowsFromAggregates(rows.filter((row) => row.dimension === "model").flatMap(toModelAggregate))
|
||||
const providerRows = providerRowsFromAggregates(
|
||||
rows.filter((row) => row.dimension === "provider").flatMap(toProviderAggregate),
|
||||
)
|
||||
const geoRows = geoRowsFromAggregates(
|
||||
rows.filter((row) => row.dimension === "geo" || row.dimension === "geo_model").flatMap(toGeoAggregate),
|
||||
)
|
||||
|
||||
yield* Effect.all([modelStats.upsert(modelRows), providerStats.upsert(providerRows), geoStats.upsert(geoRows)], {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
yield* Effect.all(
|
||||
[
|
||||
modelStats.deleteRetiredDimensions(modelRows),
|
||||
providerStats.deleteRetiredDimensions(providerRows),
|
||||
geoStats.deleteRetiredDimensions(geoRows),
|
||||
],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
|
||||
yield* Effect.logInfo(
|
||||
`stats sync complete ${JSON.stringify({
|
||||
startedAt: startedAt.toISOString(),
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
rows: modelRows.length,
|
||||
providerRows: providerRows.length,
|
||||
geoRows: geoRows.length,
|
||||
stage: Resource.App.stage,
|
||||
})}`,
|
||||
)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rows: modelRows.length,
|
||||
startedAt: startedAt.toISOString(),
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
// May 27 was partial, so keep Athena stats anchored at the first complete day.
|
||||
function fullPeriodStart(periodEnd: Date) {
|
||||
return new Date(
|
||||
Math.max(
|
||||
Math.min(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, periodEnd.getTime() - DISPLAY_WINDOW_MS),
|
||||
STATS_DATA_START_MS,
|
||||
),
|
||||
)
|
||||
const athena = yield* Athena
|
||||
const modelStats = yield* ModelStatRepo
|
||||
const providerStats = yield* ProviderStatRepo
|
||||
const geoStats = yield* GeoStatRepo
|
||||
}
|
||||
|
||||
yield* logRuntimeCheck()
|
||||
|
||||
const [modelAggregates, providerAggregates, geoAggregates, geoModelAggregates] = yield* Effect.all(
|
||||
[
|
||||
athena
|
||||
.query(buildStatsQuery(periodStart, periodEnd, "model"))
|
||||
.pipe(Effect.map((rows) => rows.flatMap(toModelAggregate))),
|
||||
athena
|
||||
.query(buildStatsQuery(periodStart, periodEnd, "provider"))
|
||||
.pipe(Effect.map((rows) => rows.flatMap(toProviderAggregate))),
|
||||
athena
|
||||
.query(buildStatsQuery(periodStart, periodEnd, "geo"))
|
||||
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
|
||||
athena
|
||||
.query(buildStatsQuery(periodStart, periodEnd, "geo_model"))
|
||||
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
// Events are append-only, so completed periods never change once synced; hourly
|
||||
// passes only recompute the periods the current ISO week can still touch. The daily
|
||||
// full pass refreshes the whole display window (normalization changes, retired
|
||||
// dimension cleanup).
|
||||
function incrementalPeriodStart(periodEnd: Date) {
|
||||
return new Date(
|
||||
Math.max(startOfIsoWeek(new Date(periodEnd.getTime() - INCREMENTAL_LOOKBACK_MS)).getTime(), STATS_DATA_START_MS),
|
||||
)
|
||||
const modelRows = modelRowsFromAggregates(modelAggregates)
|
||||
const providerRows = providerRowsFromAggregates(providerAggregates)
|
||||
const geoRows = geoRowsFromAggregates([...geoAggregates, ...geoModelAggregates])
|
||||
|
||||
yield* Effect.all([modelStats.upsert(modelRows), providerStats.upsert(providerRows), geoStats.upsert(geoRows)], {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
yield* Effect.all(
|
||||
[
|
||||
modelStats.deleteRetiredDimensions(modelRows),
|
||||
providerStats.deleteRetiredDimensions(providerRows),
|
||||
geoStats.deleteRetiredDimensions(geoRows),
|
||||
],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
|
||||
yield* Effect.logInfo(
|
||||
`stats sync complete ${JSON.stringify({
|
||||
startedAt: startedAt.toISOString(),
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
rows: modelRows.length,
|
||||
providerRows: providerRows.length,
|
||||
geoRows: geoRows.length,
|
||||
stage: Resource.App.stage,
|
||||
})}`,
|
||||
)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rows: modelRows.length,
|
||||
startedAt: startedAt.toISOString(),
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function logRuntimeCheck() {
|
||||
return Effect.logInfo(
|
||||
|
|
|
|||
|
|
@ -1,21 +1,49 @@
|
|||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||
import { Athena } from "@opencode-ai/stats-core/athena"
|
||||
import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model"
|
||||
import { layer as statsLayer } from "@opencode-ai/stats-core/runtime"
|
||||
import { syncStats } from "@opencode-ai/stats-core/stat-sync"
|
||||
import { Cause, Effect, Layer, Schedule } from "effect"
|
||||
import { Cause, Duration, Effect, Layer, Schedule } from "effect"
|
||||
|
||||
const SYNC_INTERVAL = "1 hour"
|
||||
const SYNC_INTERVAL_MS = 3_600_000
|
||||
|
||||
const runtimeLayer = Layer.mergeAll(statsLayer, Athena.layer)
|
||||
const syncPass = syncStats().pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`),
|
||||
),
|
||||
)
|
||||
const daemon = Effect.logInfo("stats sync daemon started").pipe(
|
||||
Effect.andThen(syncPass.pipe(Effect.repeat(Schedule.fixed(SYNC_INTERVAL)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const daemon = Effect.gen(function* () {
|
||||
yield* Effect.logInfo("stats sync daemon started")
|
||||
yield* initialDelay()
|
||||
|
||||
// One full pass per UTC day (including the first pass after boot) refreshes the
|
||||
// whole display window; every other pass only recomputes the current ISO week.
|
||||
let lastFullDay = ""
|
||||
const pass = Effect.gen(function* () {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const full = lastFullDay !== today
|
||||
yield* syncStats({ full })
|
||||
if (full) lastFullDay = today
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`),
|
||||
),
|
||||
)
|
||||
yield* pass.pipe(Effect.repeat(Schedule.fixed(SYNC_INTERVAL)))
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
// A restarted daemon must not immediately re-run the expensive Athena pass; resume
|
||||
// the hourly cadence from the last completed sync instead. This caps the Athena
|
||||
// spend of a crash loop at one pass per interval.
|
||||
const initialDelay = Effect.fnUntraced(function* () {
|
||||
const modelStats = yield* ModelStatRepo
|
||||
const lastSynced = yield* modelStats.lastSyncedAt().pipe(Effect.catchCause(() => Effect.succeed(null)))
|
||||
if (!lastSynced) return
|
||||
const delayMs = Math.min(SYNC_INTERVAL_MS - (Date.now() - lastSynced.getTime()), SYNC_INTERVAL_MS)
|
||||
if (delayMs <= 0) return
|
||||
yield* Effect.logInfo(
|
||||
`stats sync delaying first pass ${JSON.stringify({ lastSyncedAt: lastSynced.toISOString(), delayMs })}`,
|
||||
)
|
||||
yield* Effect.sleep(Duration.millis(delayMs))
|
||||
})
|
||||
|
||||
NodeRuntime.runMain(Layer.launch(Layer.effectDiscard(daemon).pipe(Layer.provide(runtimeLayer))), {
|
||||
disableErrorReporting: true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue