chore: merge dev into v2 (#35962)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.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>
This commit is contained in:
co-authored by
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
James Long
Aiden Cline
Dustin Deus
starptech
Frank
Luke Parker
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Dax
usrnk1
Jay
Brendan Allan
Brendan Allan
runvip
Shoubhit Dash
Jack
opencode
Aarav Sareen
Julian Coy
Vladimir Glafirov
Adam
Kit Langton
Simon Klee
Jay
David Hill
James Long
冯基魁
Aiden Cline
parent
2347f4d7b4
commit
687dbba6a3
@@ -210,25 +210,30 @@ const en = {
|
||||
"model.totalModels": "{{count}} models",
|
||||
"model.momentum": "Momentum",
|
||||
"model.vsPreviousWindow": "vs previous window",
|
||||
"model.usageDescription": "Daily OpenCode Go token volume over the recent two-month window.",
|
||||
"model.usageDescription": "Daily token volume.",
|
||||
"model.noUsageTitle": "No usage",
|
||||
"model.noUsageDescription": "No usage landed in the current window.",
|
||||
"model.dailyTokenChart": "Daily token usage chart",
|
||||
"model.usersDescription": "Daily unique OpenCode Go users over the recent two-month window.",
|
||||
"model.usersDescription": "Daily unique users.",
|
||||
"model.noUsersTitle": "No user data",
|
||||
"model.noUsersDescription": "No user-bearing rows landed in the current window.",
|
||||
"model.dailyUserChart": "Daily unique user chart",
|
||||
"model.efficiencyDescription": "Cost, cache behavior, and average OpenCode Go session shape.",
|
||||
"model.efficiencyDescription": "Cost, cache behavior, and average session shape.",
|
||||
"model.noEfficiencyTitle": "No efficiency data",
|
||||
"model.noEfficiencyDescription": "Efficiency data appears after usage lands.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.cost": "Cost",
|
||||
"model.totalSpend": "total spend",
|
||||
"model.costPerMillion": "Cost / 1M",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.inputOutput": "input / output",
|
||||
"model.observedTokens": "observed all tokens",
|
||||
"model.costSession": "Cost / Session",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.average": "average",
|
||||
"model.tokensSession": "Tokens / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cacheRatio": "Cache Ratio",
|
||||
"model.inputTokens": "input tokens",
|
||||
"model.geoDescription": "OpenCode Go model tokens used by country.",
|
||||
|
||||
@@ -202,6 +202,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "التكلفة، وسلوك التخزين المؤقت، ومتوسط شكل جلسة OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "لا توجد بيانات كفاءة",
|
||||
"model.noEfficiencyDescription": "تظهر بيانات الكفاءة بعد وصول الاستخدام.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "التكلفة",
|
||||
"model.totalSpend": "إجمالي الإنفاق",
|
||||
"model.costPerMillion": "التكلفة / 1M",
|
||||
|
||||
@@ -204,6 +204,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Custo, comportamento de cache e formato médio da sessão do OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "Sem dados de eficiência",
|
||||
"model.noEfficiencyDescription": "Os dados de eficiência aparecem depois que o uso chega.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Custo",
|
||||
"model.totalSpend": "gasto total",
|
||||
"model.costPerMillion": "Custo / 1M",
|
||||
|
||||
@@ -203,6 +203,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Omkostning, cacheadfærd og gennemsnitlig OpenCode Go-sessionsform.",
|
||||
"model.noEfficiencyTitle": "Ingen effektivitetsdata",
|
||||
"model.noEfficiencyDescription": "Effektivitetsdata vises, når brug lander.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Omkostning",
|
||||
"model.totalSpend": "samlet forbrug",
|
||||
"model.costPerMillion": "Omkostning / 1 mio.",
|
||||
|
||||
@@ -204,6 +204,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Kosten, Cache-Verhalten und durchschnittliche OpenCode Go-Sitzungsform.",
|
||||
"model.noEfficiencyTitle": "Keine Effizienzdaten",
|
||||
"model.noEfficiencyDescription": "Effizienzdaten erscheinen, nachdem Nutzung eingegangen ist.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Kosten",
|
||||
"model.totalSpend": "Gesamtausgaben",
|
||||
"model.costPerMillion": "Kosten / 1 Mio.",
|
||||
|
||||
@@ -203,6 +203,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Coste, comportamiento de caché y forma media de sesión de OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "Sin datos de eficiencia",
|
||||
"model.noEfficiencyDescription": "Los datos de eficiencia aparecen después de que llegue uso.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Coste",
|
||||
"model.totalSpend": "gasto total",
|
||||
"model.costPerMillion": "Coste / 1 M",
|
||||
|
||||
@@ -205,6 +205,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Coût, comportement du cache et forme moyenne des sessions OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "Aucune donnée d'efficacité",
|
||||
"model.noEfficiencyDescription": "Les données d'efficacité apparaissent après l'arrivée de l'utilisation.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Coût",
|
||||
"model.totalSpend": "dépense totale",
|
||||
"model.costPerMillion": "Coût / 1 M",
|
||||
|
||||
@@ -204,6 +204,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Costi, comportamento della cache e forma media della sessione OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "Nessun dato di efficienza",
|
||||
"model.noEfficiencyDescription": "I dati di efficienza appaiono dopo l'arrivo dell'utilizzo.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Costo",
|
||||
"model.totalSpend": "spesa totale",
|
||||
"model.costPerMillion": "Costo / 1 M",
|
||||
|
||||
@@ -205,6 +205,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "コスト、キャッシュ動作、平均的なOpenCode Goセッションの形。",
|
||||
"model.noEfficiencyTitle": "効率データがありません",
|
||||
"model.noEfficiencyDescription": "使用量が届くと効率データが表示されます。",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "コスト",
|
||||
"model.totalSpend": "総支出",
|
||||
"model.costPerMillion": "100万あたりのコスト",
|
||||
|
||||
@@ -205,6 +205,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "비용, 캐시 동작, 평균 OpenCode Go 세션 형태입니다.",
|
||||
"model.noEfficiencyTitle": "효율 데이터 없음",
|
||||
"model.noEfficiencyDescription": "사용량이 들어오면 효율 데이터가 표시됩니다.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "비용",
|
||||
"model.totalSpend": "총 지출",
|
||||
"model.costPerMillion": "100만당 비용",
|
||||
|
||||
@@ -203,6 +203,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Kostnad, cacheatferd og gjennomsnittlig OpenCode Go-øktform.",
|
||||
"model.noEfficiencyTitle": "Ingen effektivitetsdata",
|
||||
"model.noEfficiencyDescription": "Effektivitetsdata vises etter at bruk lander.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Kostnad",
|
||||
"model.totalSpend": "totalt forbruk",
|
||||
"model.costPerMillion": "Kostnad / 1 mill.",
|
||||
|
||||
@@ -202,6 +202,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Koszt, zachowanie cache i średni kształt sesji OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "Brak danych wydajności",
|
||||
"model.noEfficiencyDescription": "Dane wydajności pojawią się po nadejściu użycia.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Koszt",
|
||||
"model.totalSpend": "łączne wydatki",
|
||||
"model.costPerMillion": "Koszt / 1 mln",
|
||||
|
||||
@@ -204,6 +204,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Стоимость, поведение кэша и средняя форма сеанса OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "Нет данных эффективности",
|
||||
"model.noEfficiencyDescription": "Данные эффективности появятся после использования.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Стоимость",
|
||||
"model.totalSpend": "общие расходы",
|
||||
"model.costPerMillion": "Стоимость / 1 млн",
|
||||
|
||||
@@ -204,6 +204,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "ต้นทุน พฤติกรรมแคช และรูปแบบเซสชัน OpenCode Go โดยเฉลี่ย",
|
||||
"model.noEfficiencyTitle": "ไม่มีข้อมูลประสิทธิภาพ",
|
||||
"model.noEfficiencyDescription": "ข้อมูลประสิทธิภาพจะแสดงหลังจากมีการใช้งานเข้ามา",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "ต้นทุน",
|
||||
"model.totalSpend": "ยอดใช้จ่ายรวม",
|
||||
"model.costPerMillion": "ต้นทุน / 1 ล้าน",
|
||||
|
||||
@@ -204,6 +204,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Maliyet, önbellek davranışı ve ortalama OpenCode Go oturum yapısı.",
|
||||
"model.noEfficiencyTitle": "Verimlilik verisi yok",
|
||||
"model.noEfficiencyDescription": "Verimlilik verileri kullanım geldikten sonra görünür.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Maliyet",
|
||||
"model.totalSpend": "toplam harcama",
|
||||
"model.costPerMillion": "Maliyet / 1M",
|
||||
|
||||
@@ -204,6 +204,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "Вартість, поведінка кешу та середня форма сеансу OpenCode Go.",
|
||||
"model.noEfficiencyTitle": "Немає даних ефективності",
|
||||
"model.noEfficiencyDescription": "Дані ефективності з'являться після використання.",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "Вартість",
|
||||
"model.totalSpend": "загальні витрати",
|
||||
"model.costPerMillion": "Вартість / 1 млн",
|
||||
|
||||
@@ -203,6 +203,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "成本、缓存行为和平均 OpenCode Go 会话形态。",
|
||||
"model.noEfficiencyTitle": "无效率数据",
|
||||
"model.noEfficiencyDescription": "使用量到达后会显示效率数据。",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "成本",
|
||||
"model.totalSpend": "总支出",
|
||||
"model.costPerMillion": "每 100 万成本",
|
||||
|
||||
@@ -203,6 +203,11 @@ export const dict = {
|
||||
"model.efficiencyDescription": "成本、快取行為和平均 OpenCode Go 工作階段形態。",
|
||||
"model.noEfficiencyTitle": "無效率數據",
|
||||
"model.noEfficiencyDescription": "使用量到達後會顯示效率數據。",
|
||||
"model.totalSpendLabel": "Total spend",
|
||||
"model.costInput": "Cost input",
|
||||
"model.costOutput": "Cost output",
|
||||
"model.averageCostSession": "Average cost / Session",
|
||||
"model.averageTokensSession": "Average tokens / session",
|
||||
"model.cost": "成本",
|
||||
"model.totalSpend": "總支出",
|
||||
"model.costPerMillion": "每 100 萬成本",
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type UsageRange,
|
||||
} from "@opencode-ai/stats-core/domain/home"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js"
|
||||
import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
@@ -28,12 +28,17 @@ import {
|
||||
formatCatalogLabName,
|
||||
getModelCatalog,
|
||||
type ModelCatalog,
|
||||
type ModelCatalogCost,
|
||||
type ModelCatalogEntry,
|
||||
} from "../model-catalog"
|
||||
import { SectionHeading } from "../section-heading"
|
||||
import { runStatsEffect } from "../../stats-runtime"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
import {
|
||||
ComparisonCardsSection,
|
||||
modelRefFromCatalog,
|
||||
uniqueComparisonPairs,
|
||||
type ComparisonModelRef,
|
||||
} from "../compare-cards"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
@@ -121,6 +126,8 @@ export default function StatsModel() {
|
||||
const modelHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
|
||||
{ href: "#overview", label: i18n.t("nav.overview") },
|
||||
{ href: "#momentum", label: i18n.t("model.momentum") },
|
||||
{ href: "#usage", label: i18n.t("nav.usage") },
|
||||
{ href: "#unique-users", label: i18n.t("model.uniqueUsers") },
|
||||
{ href: "#efficiency", label: i18n.t("nav.efficiency") },
|
||||
{ href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
|
||||
{ href: "#peers", label: i18n.t("nav.peers") },
|
||||
@@ -183,9 +190,16 @@ export default function StatsModel() {
|
||||
/>
|
||||
<ModelOverview catalog={catalogEntry() ?? null} />
|
||||
<ModelMomentumSection data={stats() ?? null} />
|
||||
<ModelUsageSection data={stats() ?? null} />
|
||||
<ModelUniqueUsersSection data={stats() ?? null} />
|
||||
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
|
||||
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
|
||||
<ModelPeersSection data={stats() ?? null} />
|
||||
<ComparisonCardsSection
|
||||
pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)}
|
||||
title="Compare This Model"
|
||||
description="Other models to compare with this one."
|
||||
/>
|
||||
</>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -194,6 +208,7 @@ export default function StatsModel() {
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
links={modelFooterLinks()}
|
||||
bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -561,6 +576,303 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUsageSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<ModelTrendSection
|
||||
data={props.data}
|
||||
id="usage"
|
||||
title={i18n.t("nav.usage")}
|
||||
description={i18n.t("model.usageDescription")}
|
||||
ariaLabel={i18n.t("model.dailyTokenChart")}
|
||||
emptyTitle={i18n.t("model.noUsageTitle")}
|
||||
emptyDescription={i18n.t("model.noUsageDescription")}
|
||||
value={(point) => point.tokens}
|
||||
formatValue={formatTokens}
|
||||
valueUnit={i18n.t("lab.tokens")}
|
||||
rowLabel={i18n.t("lab.dailyTokens")}
|
||||
lineTone="muted"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<ModelTrendSection
|
||||
data={props.data}
|
||||
id="unique-users"
|
||||
title={i18n.t("model.uniqueUsers")}
|
||||
description={i18n.t("model.usersDescription")}
|
||||
ariaLabel={i18n.t("model.dailyUserChart")}
|
||||
emptyTitle={i18n.t("model.noUsersTitle")}
|
||||
emptyDescription={i18n.t("model.noUsersDescription")}
|
||||
value={(point) => point.users}
|
||||
formatValue={formatUsers}
|
||||
valueUnit={i18n.t("format.users")}
|
||||
rowLabel={i18n.t("model.uniqueUsers")}
|
||||
lineTone="active"
|
||||
activeLineBaseTone="muted"
|
||||
highlightBars={false}
|
||||
area
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelTrendSection(props: {
|
||||
data: StatsModelData | null
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
ariaLabel: string
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
value: (point: ModelUsagePoint) => number
|
||||
formatValue: (value: number) => string
|
||||
valueUnit: string
|
||||
rowLabel: string
|
||||
lineTone: "muted" | "active"
|
||||
activeLineBaseTone?: "muted" | "active"
|
||||
highlightBars?: boolean
|
||||
area?: boolean
|
||||
}) {
|
||||
const activeLineClipId = createUniqueId()
|
||||
const activeLineMaskId = createUniqueId()
|
||||
const areaGradientId = createUniqueId()
|
||||
const [activeIndex, setActiveIndex] = createSignal<number>()
|
||||
const highlightBars = createMemo(() => props.highlightBars ?? true)
|
||||
const lineScale = 326 / 450
|
||||
const areaBottom = 100 / lineScale
|
||||
const usage = createMemo(() => props.data?.usage ?? [])
|
||||
const valueMax = createMemo(() => Math.max(0, ...usage().map((item) => props.value(item))) || 1)
|
||||
const linePoints = createMemo(() =>
|
||||
usage().map((point, index) => ({
|
||||
point,
|
||||
x: modelUsagePointX(index, usage().length),
|
||||
y: modelUsageLineY(props.value(point), valueMax()),
|
||||
})),
|
||||
)
|
||||
const linePath = createMemo(() => modelUsageLinePath(linePoints()))
|
||||
const areaPath = createMemo(() => modelUsageAreaPath(linePoints(), areaBottom))
|
||||
const activeLineBreak = createMemo(() => {
|
||||
const index = activeIndex()
|
||||
if (index === undefined || linePoints().length < 2) return undefined
|
||||
return modelUsageColumnBounds(index, linePoints().length)
|
||||
})
|
||||
const activeLineClip = createMemo(() => {
|
||||
const index = activeIndex()
|
||||
if (index === undefined || linePoints().length < 2) return undefined
|
||||
return modelUsageColumnInnerBounds(index, linePoints().length)
|
||||
})
|
||||
const activeTooltip = createMemo(() => {
|
||||
const index = activeIndex()
|
||||
const point = index === undefined ? undefined : usage()[index]
|
||||
if (index === undefined || !point) return undefined
|
||||
const bounds = modelUsageColumnBounds(index, usage().length)
|
||||
return {
|
||||
bounds,
|
||||
index,
|
||||
point,
|
||||
tooltipY: (linePoints()[index]?.y ?? 100) * lineScale,
|
||||
}
|
||||
})
|
||||
const monthTicks = createMemo(() => modelUsageMonthTicks(usage(), props.data?.updatedAt ?? null))
|
||||
|
||||
return (
|
||||
<section id={props.id} data-section="model-panel">
|
||||
<SectionTitle href={`#${props.id}`} title={props.title} description={props.description} />
|
||||
<Show
|
||||
when={usage().some((item) => props.value(item) > 0)}
|
||||
fallback={<ModelEmptyState title={props.emptyTitle} description={props.emptyDescription} />}
|
||||
>
|
||||
<div
|
||||
data-component="model-usage-chart"
|
||||
data-variant="model-trend"
|
||||
data-highlight-bars={highlightBars() ? "true" : undefined}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel}
|
||||
style={{ "--model-usage-count": usage().length } as JSX.CSSProperties}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(undefined)
|
||||
}}
|
||||
>
|
||||
<div data-slot="model-trend-plot">
|
||||
<Show when={linePath()}>
|
||||
{(path) => (
|
||||
<>
|
||||
<Show when={props.area && areaPath()}>
|
||||
{(area) => (
|
||||
<svg
|
||||
data-slot="model-trend-area-layer"
|
||||
viewBox={`0 0 100 ${formatUsagePathNumber(areaBottom)}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={areaGradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--model-trend-active)" stop-opacity="0.1" />
|
||||
<stop offset="66%" stop-color="var(--model-trend-active)" stop-opacity="0.045" />
|
||||
<stop offset="100%" stop-color="var(--model-trend-active)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path data-slot="model-trend-area" d={area()} fill={`url(#${areaGradientId})`} />
|
||||
</svg>
|
||||
)}
|
||||
</Show>
|
||||
<svg
|
||||
data-slot="model-trend-line"
|
||||
data-layer="base"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show
|
||||
when={activeLineBreak()}
|
||||
fallback={<path data-slot="model-trend-line-base" data-tone={props.lineTone} d={path()} />}
|
||||
>
|
||||
{(lineBreak) => (
|
||||
<>
|
||||
<defs>
|
||||
<mask id={activeLineMaskId} maskUnits="userSpaceOnUse">
|
||||
<rect x="0" y="-2" width="100" height="104" fill="white" />
|
||||
<rect x={lineBreak().x} y="-2" width={lineBreak().width} height="104" fill="black" />
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
data-slot="model-trend-line-base"
|
||||
data-tone={props.activeLineBaseTone ?? props.lineTone}
|
||||
d={path()}
|
||||
mask={`url(#${activeLineMaskId})`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</svg>
|
||||
<Show when={activeLineClip()}>
|
||||
{(clip) => (
|
||||
<svg
|
||||
data-slot="model-trend-line"
|
||||
data-layer="active"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={activeLineClipId} clipPathUnits="userSpaceOnUse">
|
||||
<rect x={clip().x} y="-2" width={clip().width} height="104" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path data-slot="model-trend-line-active" d={path()} clip-path={`url(#${activeLineClipId})`} />
|
||||
</svg>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={linePoints().at(-1)}>
|
||||
{(point) => (
|
||||
<span
|
||||
data-slot="model-trend-end-marker"
|
||||
data-tone={props.lineTone}
|
||||
aria-hidden="true"
|
||||
style={
|
||||
{
|
||||
"--model-trend-end-x": `${point().x}%`,
|
||||
"--model-trend-end-top": `${point().y * lineScale}%`,
|
||||
} as JSX.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<div data-slot="model-trend-bars">
|
||||
<For each={usage()}>
|
||||
{(point, index) => (
|
||||
<div
|
||||
data-slot="model-trend-column"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${point.date} ${props.formatValue(props.value(point))} ${props.valueUnit}`}
|
||||
data-active={activeIndex() === index() ? "true" : undefined}
|
||||
data-muted={
|
||||
highlightBars() && activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined
|
||||
}
|
||||
style={
|
||||
{
|
||||
"--model-trend-token-height": modelUsageStripHeight(props.value(point), valueMax()),
|
||||
} as JSX.CSSProperties
|
||||
}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") return
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
onPointerEnter={() => setActiveIndex(index())}
|
||||
onPointerMove={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
onClick={() => setActiveIndex(index())}
|
||||
onFocus={() => setActiveIndex(index())}
|
||||
onBlur={() => setActiveIndex(undefined)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
>
|
||||
<div data-slot="model-trend-token-band">
|
||||
<div data-slot="model-trend-token-bar" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={activeTooltip()} keyed>
|
||||
{(active) => (
|
||||
<div
|
||||
data-component="chart-tooltip"
|
||||
data-placement={active.index > usage().length * 0.62 ? "left" : "right"}
|
||||
style={
|
||||
{
|
||||
"--model-trend-tooltip-left": `${active.bounds.x}%`,
|
||||
"--model-trend-tooltip-right": `${active.bounds.x + active.bounds.width}%`,
|
||||
"--model-trend-tooltip-y": `${active.tooltipY}`,
|
||||
} as JSX.CSSProperties
|
||||
}
|
||||
>
|
||||
<strong>{formatModelUsageTooltipDate(active.point.date, props.data?.updatedAt ?? null)}</strong>
|
||||
<span>
|
||||
{props.formatValue(props.value(active.point))} {props.valueUnit}
|
||||
</span>
|
||||
<div data-slot="tooltip-divider" />
|
||||
<p>
|
||||
<span data-slot="tooltip-label">
|
||||
<i data-kind={props.lineTone === "active" ? "users" : "tokens"} /> {props.rowLabel}
|
||||
</span>
|
||||
<b>{props.formatValue(props.value(active.point))}</b>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="model-trend-months" aria-hidden="true">
|
||||
<For each={monthTicks()}>
|
||||
{(tick) => (
|
||||
<span
|
||||
data-align={tick.align}
|
||||
style={{ "--model-trend-month-left": `${tick.left}%` } as JSX.CSSProperties}
|
||||
>
|
||||
{tick.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
@@ -580,35 +892,32 @@ function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: M
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<div data-component="model-metric-grid" data-variant="dense">
|
||||
<MetricCard
|
||||
label={i18n.t("model.cost")}
|
||||
value={formatMoney(data().totals.cost)}
|
||||
detail={i18n.t("model.totalSpend")}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.costPerMillion")}
|
||||
value={
|
||||
props.catalog?.cost ? formatCatalogPrice(props.catalog.cost) : formatMoney(data().totals.costPerMillion)
|
||||
}
|
||||
detail={props.catalog?.cost ? i18n.t("model.inputOutput") : i18n.t("model.observedTokens")}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.costSession")}
|
||||
value={formatSessionCost(data().totals.costPerSession)}
|
||||
detail={i18n.t("model.average")}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.tokensSession")}
|
||||
value={formatTokens(data().totals.tokensPerSession)}
|
||||
detail={i18n.t("model.average")}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.cacheRatio")}
|
||||
value={formatPercent(data().totals.cacheRatio)}
|
||||
detail={i18n.t("model.inputTokens")}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div data-slot="model-efficiency-pattern" aria-hidden="true" />
|
||||
<div data-component="model-efficiency-grid">
|
||||
<MetricCard label={i18n.t("model.totalSpendLabel")} value={formatMoney(data().totals.cost)} />
|
||||
<MetricCard
|
||||
label={i18n.t("model.costInput")}
|
||||
value={formatCatalogUnitPrice(props.catalog?.cost?.input)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.costOutput")}
|
||||
value={formatCatalogUnitPrice(props.catalog?.cost?.output)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.averageCostSession")}
|
||||
value={formatSessionCost(data().totals.costPerSession)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.averageTokensSession")}
|
||||
value={formatTokens(data().totals.tokensPerSession)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={i18n.t("model.cacheRatio")}
|
||||
value={`${formatPercent(data().totals.cacheRatio)} ${i18n.t("model.inputTokens")}`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
@@ -828,12 +1137,12 @@ function ModelPeersSection(props: { data: StatsModelData | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard(props: { label: string; value: string; detail: string; state?: "positive" | "negative" }) {
|
||||
function MetricCard(props: { label: string; value: string; detail?: string; state?: "positive" | "negative" }) {
|
||||
return (
|
||||
<article data-component="model-metric" data-state={props.state}>
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
<p>{props.detail}</p>
|
||||
<Show when={props.detail}>{(detail) => <p>{detail()}</p>}</Show>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -846,10 +1155,16 @@ function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) {
|
||||
href={language.route(`${import.meta.env.BASE_URL}${providerSlug(props.peer.provider)}/${props.peer.slug}`)}
|
||||
data-active={props.active ? "true" : undefined}
|
||||
>
|
||||
<span>{String(props.peer.rank).padStart(2, "0")}</span>
|
||||
<ProviderIcon aria-hidden="true" id={getProviderIconId(props.peer.author)} />
|
||||
<strong>{props.peer.model}</strong>
|
||||
<em>{props.peer.author}</em>
|
||||
<span data-slot="model-peer-rank" aria-label={props.active ? `Rank ${props.peer.rank}` : undefined}>
|
||||
<Show when={!props.active}>{String(props.peer.rank).padStart(2, "0")}</Show>
|
||||
</span>
|
||||
<span data-slot="model-peer-avatar">
|
||||
<ProviderIcon aria-hidden="true" id={getProviderIconId(props.peer.author)} />
|
||||
</span>
|
||||
<span data-slot="model-peer-copy">
|
||||
<strong>{props.peer.model}</strong>
|
||||
<em>{props.peer.author}</em>
|
||||
</span>
|
||||
<b>{formatTokens(props.peer.tokens)}</b>
|
||||
</a>
|
||||
</li>
|
||||
@@ -869,6 +1184,55 @@ function ModelEmptyState(props: { title: string; description: string; compact?:
|
||||
)
|
||||
}
|
||||
|
||||
function modelComparisonPairs(
|
||||
catalog: ModelCatalog | undefined,
|
||||
catalogEntry: ModelCatalogEntry | null,
|
||||
data: StatsModelData | null,
|
||||
) {
|
||||
const current = modelComparisonRef(catalogEntry, data)
|
||||
if (!current) return []
|
||||
const peerPairs = (data?.peers ?? [])
|
||||
.filter((peer) => peer.model !== data?.model)
|
||||
.slice(0, 3)
|
||||
.map((peer) => ({
|
||||
first: current,
|
||||
second: {
|
||||
name: peer.model,
|
||||
lab: peer.provider,
|
||||
slug: peer.slug,
|
||||
labName: peer.author,
|
||||
metric: `#${peer.rank} / ${formatTokens(peer.tokens)}`,
|
||||
},
|
||||
detail: "Usage peer",
|
||||
}))
|
||||
const catalogPairs = (
|
||||
catalogEntry && catalog ? (catalog.labs.find((lab) => lab.id === catalogEntry.lab)?.models ?? []) : []
|
||||
)
|
||||
.filter((model) => model.id !== catalogEntry?.id)
|
||||
.slice(0, 3)
|
||||
.map((model) => ({
|
||||
first: current,
|
||||
second: modelRefFromCatalog(model),
|
||||
detail: "Same lab pair",
|
||||
}))
|
||||
return uniqueComparisonPairs([...peerPairs, ...catalogPairs])
|
||||
}
|
||||
|
||||
function modelComparisonRef(
|
||||
catalogEntry: ModelCatalogEntry | null,
|
||||
data: StatsModelData | null,
|
||||
): ComparisonModelRef | undefined {
|
||||
if (catalogEntry) return modelRefFromCatalog(catalogEntry)
|
||||
if (!data) return undefined
|
||||
return {
|
||||
name: data.model,
|
||||
lab: data.provider,
|
||||
slug: data.slug,
|
||||
labName: data.author,
|
||||
metric: `#${data.rank}`,
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderIconId(author: string) {
|
||||
if (author === "MiniMax") return "minimax"
|
||||
if (author === "Moonshot") return "moonshotai"
|
||||
@@ -1014,6 +1378,101 @@ function formatMomentumDateLabel(date: string) {
|
||||
return `${shortMonths[parsed.getUTCMonth()]} ${parsed.getUTCDate()}`
|
||||
}
|
||||
|
||||
function modelUsageStripHeight(value: number, max: number) {
|
||||
if (value <= 0 || max <= 0) return "0px"
|
||||
return `max(2px, ${(value / max) * 100}%)`
|
||||
}
|
||||
|
||||
function modelUsageLineY(value: number, max: number) {
|
||||
if (value <= 0 || max <= 0) return 100
|
||||
return Math.max(0, 100 - (value / max) * 100)
|
||||
}
|
||||
|
||||
function modelUsagePointX(index: number, count: number) {
|
||||
if (count <= 1) return 50
|
||||
return ((index + 0.5) / count) * 100
|
||||
}
|
||||
|
||||
function modelUsageColumnBounds(index: number, count: number) {
|
||||
if (count <= 0) return { x: 0, width: 100 }
|
||||
return { x: (index / count) * 100, width: 100 / count }
|
||||
}
|
||||
|
||||
function modelUsageColumnInnerBounds(index: number, count: number) {
|
||||
const bounds = modelUsageColumnBounds(index, count)
|
||||
const inset = Math.min(bounds.width * 0.1, 0.24)
|
||||
return { x: bounds.x + inset, width: Math.max(0.001, bounds.width - inset * 2) }
|
||||
}
|
||||
|
||||
type ModelUsageLinePoint = { x: number; y: number }
|
||||
|
||||
function modelUsageLinePath(points: ModelUsageLinePoint[]) {
|
||||
if (points.length === 0) return ""
|
||||
if (points.length === 1) return `M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}`
|
||||
|
||||
return points.slice(0, -1).reduce(
|
||||
(path, point, index) => {
|
||||
const next = points[index + 1]
|
||||
const previous = points[index - 1] ?? point
|
||||
const afterNext = points[index + 2] ?? next
|
||||
const controlA = {
|
||||
x: point.x + (next.x - previous.x) / 6,
|
||||
y: clampUsagePercent(point.y + (next.y - previous.y) / 6),
|
||||
}
|
||||
const controlB = {
|
||||
x: next.x - (afterNext.x - point.x) / 6,
|
||||
y: clampUsagePercent(next.y - (afterNext.y - point.y) / 6),
|
||||
}
|
||||
return `${path} C ${formatUsagePathNumber(controlA.x)} ${formatUsagePathNumber(controlA.y)}, ${formatUsagePathNumber(controlB.x)} ${formatUsagePathNumber(controlB.y)}, ${formatUsagePathNumber(next.x)} ${formatUsagePathNumber(next.y)}`
|
||||
},
|
||||
`M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}`,
|
||||
)
|
||||
}
|
||||
|
||||
function modelUsageAreaPath(points: ModelUsageLinePoint[], bottom: number) {
|
||||
const path = modelUsageLinePath(points)
|
||||
if (!path || points.length === 0) return ""
|
||||
const first = points[0]
|
||||
const last = points[points.length - 1]
|
||||
return `${path} L ${formatUsagePathNumber(last.x)} ${formatUsagePathNumber(bottom)} L ${formatUsagePathNumber(first.x)} ${formatUsagePathNumber(bottom)} Z`
|
||||
}
|
||||
|
||||
function formatUsagePathNumber(value: number) {
|
||||
return Number(value.toFixed(3))
|
||||
}
|
||||
|
||||
function clampUsagePercent(value: number) {
|
||||
return Math.max(0, Math.min(100, value))
|
||||
}
|
||||
|
||||
function modelUsageMonthTicks(points: ModelUsagePoint[], updatedAt: string | null) {
|
||||
const seen = new Set<string>()
|
||||
return points.flatMap((point, index) => {
|
||||
const parsed = parseMomentumDate(point.date, updatedAt)
|
||||
const label = shortMonths[parsed.getUTCMonth()]
|
||||
if (!label || seen.has(label)) return []
|
||||
seen.add(label)
|
||||
return [
|
||||
{
|
||||
label,
|
||||
left: modelUsagePointX(index, points.length),
|
||||
align: index === 0 ? "start" : index >= points.length - 2 ? "end" : "center",
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function formatModelUsageTooltipDate(value: string, updatedAt: string | null) {
|
||||
const date = parseMomentumDate(value, updatedAt)
|
||||
if (date.getUTCFullYear() === 1970) return value
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatRankLabel(rank: number | null) {
|
||||
if (rank === null) return "--"
|
||||
return `#${String(rank).padStart(2, "0")}`
|
||||
@@ -1121,8 +1580,9 @@ function formatMoney(value: number) {
|
||||
return `$${value.toFixed(value >= 10 ? 0 : 2)}`
|
||||
}
|
||||
|
||||
function formatCatalogPrice(value: ModelCatalogCost) {
|
||||
return `${formatModelPrice(value.input)} / ${formatModelPrice(value.output)}`
|
||||
function formatCatalogUnitPrice(value: number | undefined) {
|
||||
if (value === undefined) return "-"
|
||||
return `${formatModelPrice(value)} / 1M`
|
||||
}
|
||||
|
||||
function formatModelPrice(value: number) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { SectionHeading } from "../section-heading"
|
||||
import { runStatsEffect } from "../../stats-runtime"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
@@ -149,6 +150,11 @@ export default function StatsLab() {
|
||||
labs={catalog()?.labs ?? []}
|
||||
market={homeStats()?.market["2M"] ?? []}
|
||||
/>
|
||||
<ComparisonCardsSection
|
||||
pairs={labComparisonPairs(data(), stats()?.models ?? [])}
|
||||
title={`${data().name} Model Comparisons`}
|
||||
description="Model pairs from this lab."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
@@ -158,6 +164,7 @@ export default function StatsLab() {
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
links={labFooterLinks()}
|
||||
bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -732,6 +739,31 @@ function LabEmptyState(props: { title: string; description: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function labComparisonPairs(lab: ModelCatalogLab, usage: LabUsageModelEntry[]) {
|
||||
const usageRefs = usage.slice(0, 4).map((model) => ({
|
||||
name: model.model,
|
||||
lab: model.provider,
|
||||
slug: model.slug,
|
||||
labName: model.author,
|
||||
metric: formatTokens(model.tokens),
|
||||
}))
|
||||
const refs = usageRefs.length > 1 ? usageRefs : lab.models.slice(0, 4).map(modelRefFromCatalog)
|
||||
return uniqueComparisonPairs(
|
||||
(
|
||||
[
|
||||
[0, 1, "Most-used lab pair"],
|
||||
[0, 2, "Lab alternative"],
|
||||
[1, 2, "Adjacent lab pair"],
|
||||
[2, 3, "Same lab pair"],
|
||||
] as const
|
||||
).flatMap(([firstIndex, secondIndex, detail]) => {
|
||||
const first = refs[firstIndex]
|
||||
const second = refs[secondIndex]
|
||||
return first && second ? [{ first, second, detail }] : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number }
|
||||
|
||||
function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog"
|
||||
|
||||
export type ComparisonModelRef = {
|
||||
name: string
|
||||
lab: string
|
||||
slug: string
|
||||
labName?: string
|
||||
metric?: string
|
||||
}
|
||||
|
||||
export type ComparisonPair = {
|
||||
first: ComparisonModelRef
|
||||
second: ComparisonModelRef
|
||||
detail: string
|
||||
}
|
||||
|
||||
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
|
||||
return {
|
||||
name: entry.name,
|
||||
lab: entry.lab,
|
||||
slug: entry.slug,
|
||||
labName: formatCatalogLabName(entry.lab),
|
||||
}
|
||||
}
|
||||
|
||||
export function comparisonHref(first: ComparisonModelRef, second: ComparisonModelRef) {
|
||||
return `${import.meta.env.BASE_URL}compare/${catalogSlug(first.lab)}/${catalogSlug(first.slug)}/${catalogSlug(
|
||||
second.lab,
|
||||
)}/${catalogSlug(second.slug)}`
|
||||
}
|
||||
|
||||
export function uniqueComparisonPairs(pairs: ComparisonPair[]) {
|
||||
return pairs.reduce<{ keys: Set<string>; pairs: ComparisonPair[] }>(
|
||||
(result, pair) => {
|
||||
const key = [modelKey(pair.first), modelKey(pair.second)].toSorted().join("|")
|
||||
if (result.keys.has(key) || modelKey(pair.first) === modelKey(pair.second)) return result
|
||||
result.keys.add(key)
|
||||
result.pairs.push(pair)
|
||||
return result
|
||||
},
|
||||
{ keys: new Set(), pairs: [] },
|
||||
).pairs
|
||||
}
|
||||
|
||||
export function ComparisonCardsSection(props: {
|
||||
pairs: ComparisonPair[]
|
||||
title?: string
|
||||
description?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Show when={props.pairs.length > 0}>
|
||||
<section id="model-comparison" data-section="model-panel" data-variant={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>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function modelKey(model: ComparisonModelRef) {
|
||||
return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createEffect, createMemo, createSignal, For } from "solid-js"
|
||||
import { comparisonHref, modelRefFromCatalog } from "./compare-cards"
|
||||
import type { ModelCatalogEntry } from "./model-catalog"
|
||||
|
||||
export function ComparisonSelector(props: {
|
||||
models: ModelCatalogEntry[]
|
||||
firstId?: string
|
||||
secondId?: string
|
||||
label?: string
|
||||
}) {
|
||||
const [firstId, setFirstId] = createSignal(props.firstId ?? props.models[0]?.id ?? "")
|
||||
const [secondId, setSecondId] = createSignal(differentModelId(props.models, firstId(), props.secondId))
|
||||
const modelById = createMemo(() => new Map(props.models.map((model) => [model.id, model])))
|
||||
const href = createMemo(() => {
|
||||
const first = modelById().get(firstId())
|
||||
const second = modelById().get(secondId())
|
||||
if (!first || !second || first.id === second.id) return undefined
|
||||
return comparisonHref(modelRefFromCatalog(first), modelRefFromCatalog(second))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (firstId() && modelById().has(firstId())) return
|
||||
const next = props.firstId && modelById().has(props.firstId) ? props.firstId : props.models[0]?.id
|
||||
if (next) setFirstId(next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (secondId() && secondId() !== firstId() && modelById().has(secondId())) return
|
||||
setSecondId(differentModelId(props.models, firstId(), props.secondId))
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
data-component="comparison-selector"
|
||||
aria-label={props.label ?? "Model comparison selector"}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const url = href()
|
||||
if (!url || typeof window === "undefined") return
|
||||
window.location.href = url
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>First model</span>
|
||||
<select value={firstId()} onInput={(event) => setFirstId(event.currentTarget.value)} required>
|
||||
<option value="" disabled>
|
||||
Select model
|
||||
</option>
|
||||
<For each={props.models}>
|
||||
{(model) => (
|
||||
<option value={model.id}>
|
||||
{model.name} ({model.lab})
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Second model</span>
|
||||
<select value={secondId()} onInput={(event) => setSecondId(event.currentTarget.value)} required>
|
||||
<option value="" disabled>
|
||||
Select model
|
||||
</option>
|
||||
<For each={props.models}>
|
||||
{(model) => (
|
||||
<option value={model.id} disabled={model.id === firstId()}>
|
||||
{model.name} ({model.lab})
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" disabled={!href()}>
|
||||
Compare models
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function differentModelId(models: ModelCatalogEntry[], firstId: string, preferredId: string | undefined) {
|
||||
if (preferredId && preferredId !== firstId && models.some((model) => model.id === preferredId)) return preferredId
|
||||
return models.find((model) => model.id !== firstId)?.id ?? ""
|
||||
}
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import "../index.css"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
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 { formatCatalogLabName, getModelCatalog, type ModelCatalogEntry } from "../model-catalog"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
getGitHubStars,
|
||||
Header,
|
||||
isThemePreference,
|
||||
themeStorageKey,
|
||||
type HeaderLink,
|
||||
type ThemePreference,
|
||||
} from "../stats-shell"
|
||||
|
||||
const comparePath = "/data/compare"
|
||||
const compareTitle = "AI Model Comparison"
|
||||
const compareDescription =
|
||||
"Compare AI models on key metrics including benchmarks, price, context length, and other model features."
|
||||
const statsUnfurlPath = "banner.png"
|
||||
const uptimeBars = Array.from({ length: 10 }, (_, index) => index)
|
||||
const heroLabs = [
|
||||
{ lab: "deepseek", label: "DeepSeek" },
|
||||
{ lab: "openai", label: "OpenAI" },
|
||||
{ lab: "anthropic", label: "Anthropic" },
|
||||
] as const
|
||||
const categoryTemplates = [
|
||||
{
|
||||
title: "Flagship models",
|
||||
description: "The latest top-tier model from major labs.",
|
||||
kind: "flagship",
|
||||
},
|
||||
{
|
||||
title: "Most affordable",
|
||||
description: "Compact, lower-cost picks for high-throughput use.",
|
||||
kind: "affordable",
|
||||
},
|
||||
{
|
||||
title: "Best for code",
|
||||
description: "Frequently chosen for programming tasks.",
|
||||
kind: "code",
|
||||
},
|
||||
{
|
||||
title: "Image generation",
|
||||
description: "Compare top image models on cost and quality.",
|
||||
kind: "image",
|
||||
},
|
||||
] as const
|
||||
|
||||
type CompareCategory = {
|
||||
title: string
|
||||
description: string
|
||||
first: ComparisonModelRef
|
||||
second: ComparisonModelRef
|
||||
avatars: ComparisonModelRef[]
|
||||
}
|
||||
type CompareSlot = "first" | "second"
|
||||
|
||||
export default function ModelCompareIndex() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const event = getRequestEvent()
|
||||
setStatsPageCacheHeaders(event?.response.headers)
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const compareUrl = createMemo(() => localizedUrl(language.locale(), comparePath))
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||
const featuredModels = createMemo(() => (catalog()?.models ?? []).slice(0, 120))
|
||||
const categories = createMemo(() => buildComparisonCategories(featuredModels()))
|
||||
const compareHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
|
||||
{ href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") },
|
||||
{ href: `${import.meta.env.BASE_URL}#leaderboard`, label: i18n.t("nav.leaderboard") },
|
||||
{ href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") },
|
||||
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: i18n.t("nav.tokenCost") },
|
||||
{ href: `${import.meta.env.BASE_URL}#session-cost`, label: i18n.t("nav.sessionCost") },
|
||||
])
|
||||
const compareFooterLinks = createMemo<readonly HeaderLink[]>(() => [
|
||||
{ href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") },
|
||||
{ href: `${import.meta.env.BASE_URL}#leaderboard`, label: i18n.t("nav.leaderboard") },
|
||||
{ href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") },
|
||||
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: i18n.t("nav.tokenCost") },
|
||||
{ href: `${import.meta.env.BASE_URL}#session-cost`, label: i18n.t("nav.sessionCost") },
|
||||
])
|
||||
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>{compareTitle}</Title>
|
||||
<Meta name="description" content={compareDescription} />
|
||||
<LocaleLinks path={comparePath} />
|
||||
<Meta property="og:type" content="website" />
|
||||
<Meta property="og:site_name" content="OpenCode" />
|
||||
<Meta property="og:title" content={compareTitle} />
|
||||
<Meta property="og:description" content={compareDescription} />
|
||||
<Meta property="og:url" content={compareUrl()} />
|
||||
<Meta property="og:image" content={statsUnfurlUrl} />
|
||||
<Meta property="og:image:type" content="image/png" />
|
||||
<Meta property="og:image:width" content="1200" />
|
||||
<Meta property="og:image:height" content="630" />
|
||||
<Meta property="og:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Meta name="twitter:card" content="summary_large_image" />
|
||||
<Meta name="twitter:title" content={compareTitle} />
|
||||
<Meta name="twitter:description" content={compareDescription} />
|
||||
<Meta name="twitter:image" content={statsUnfurlUrl} />
|
||||
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Header githubStars={githubStars() ?? "150K"} links={compareHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<section id="compare-tool" data-section="compare-home-hero">
|
||||
<nav data-component="compare-home-breadcrumb" aria-label="Breadcrumb">
|
||||
<a data-slot="compare-home-crumb" href={language.route(import.meta.env.BASE_URL)}>
|
||||
Data
|
||||
</a>
|
||||
<span data-slot="compare-home-separator">/</span>
|
||||
<span data-slot="compare-home-crumb" data-current="true">
|
||||
Compare
|
||||
</span>
|
||||
</nav>
|
||||
<div data-slot="compare-home-hero-grid">
|
||||
<h1 aria-label="Compare AI models">
|
||||
<span>Compare</span>
|
||||
<HeroModelStack />
|
||||
<span>AI models</span>
|
||||
</h1>
|
||||
<p>{compareDescription}</p>
|
||||
</div>
|
||||
<div data-slot="compare-home-pattern" aria-hidden="true" />
|
||||
</section>
|
||||
<section data-section="compare-home-selector" aria-label="Choose models to compare">
|
||||
<Show
|
||||
when={featuredModels().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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<Footer
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
links={compareFooterLinks()}
|
||||
bridge={null}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function CompareHomeSelector(props: { models: ModelCatalogEntry[] }) {
|
||||
const [firstId, setFirstId] = createSignal("")
|
||||
const [secondId, setSecondId] = createSignal("")
|
||||
const [activeSlot, setActiveSlot] = createSignal<CompareSlot>()
|
||||
const modelById = createMemo(() => new Map(props.models.map((model) => [model.id, model])))
|
||||
const first = createMemo(() => modelById().get(firstId()))
|
||||
const second = createMemo(() => modelById().get(secondId()))
|
||||
const activeSelected = createMemo(() => (activeSlot() === "first" ? first() : second()))
|
||||
const activeBlockedId = createMemo(() => (activeSlot() === "first" ? secondId() : firstId()))
|
||||
const href = createMemo(() => {
|
||||
const firstModel = first()
|
||||
const secondModel = second()
|
||||
if (!firstModel || !secondModel || firstModel.id === secondModel.id) return undefined
|
||||
return comparisonHref(modelRefFromCatalog(firstModel), modelRefFromCatalog(secondModel))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!secondId() || secondId() !== firstId()) return
|
||||
setSecondId("")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const url = href()
|
||||
if (!url || typeof window === "undefined") return
|
||||
window.location.href = url
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
data-component="compare-home-selector"
|
||||
aria-label="Model comparison selector"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
<CompareHomeSelect
|
||||
selected={first()}
|
||||
label="First model"
|
||||
expanded={activeSlot() === "first"}
|
||||
onOpen={() => setActiveSlot("first")}
|
||||
/>
|
||||
<CompareHomeSelect
|
||||
selected={second()}
|
||||
label="Second model"
|
||||
expanded={activeSlot() === "second"}
|
||||
onOpen={() => setActiveSlot("second")}
|
||||
/>
|
||||
<Show when={activeSlot()}>
|
||||
{(slot) => (
|
||||
<CompareModelSelectModal
|
||||
models={props.models}
|
||||
selected={activeSelected()}
|
||||
blockedId={activeBlockedId()}
|
||||
label={slot() === "first" ? "Choose first model" : "Choose second model"}
|
||||
onClose={() => setActiveSlot(undefined)}
|
||||
onSelect={(model) => {
|
||||
if (slot() === "first") setFirstId(model.id)
|
||||
if (slot() === "second") setSecondId(model.id)
|
||||
setActiveSlot(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function CompareHomeSelect(props: {
|
||||
selected: ModelCatalogEntry | undefined
|
||||
label: string
|
||||
expanded: boolean
|
||||
onOpen: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="compare-home-select-panel"
|
||||
data-selected={props.selected ? "true" : undefined}
|
||||
aria-label={props.label}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={props.expanded}
|
||||
onClick={props.onOpen}
|
||||
>
|
||||
<span data-slot="compare-home-select-copy">
|
||||
<span data-slot="compare-home-plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<Show when={props.selected}>{(model) => <ModelAvatar model={model()} size="tiny" />}</Show>
|
||||
<span data-slot="compare-home-select-name">{props.selected?.name ?? "Select a model"}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CompareModelSelectModal(props: {
|
||||
models: ModelCatalogEntry[]
|
||||
selected: ModelCatalogEntry | undefined
|
||||
blockedId: string
|
||||
label: string
|
||||
onClose: () => void
|
||||
onSelect: (model: ModelCatalogEntry) => void
|
||||
}) {
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
const [search, setSearch] = createSignal("")
|
||||
const [previewId, setPreviewId] = createSignal(props.selected?.id ?? "")
|
||||
const availableModels = createMemo(() => props.models.filter((model) => model.id !== props.blockedId))
|
||||
const filteredModels = createMemo(() => {
|
||||
const terms = search().trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (terms.length === 0) return availableModels()
|
||||
return availableModels().filter((model) => terms.every((term) => modelSearchText(model).includes(term)))
|
||||
})
|
||||
const preview = createMemo(
|
||||
() => filteredModels().find((model) => model.id === previewId()) ?? filteredModels()[0] ?? availableModels()[0],
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const selected = props.selected
|
||||
const fallback = selected && selected.id !== props.blockedId ? selected.id : filteredModels()[0]?.id
|
||||
if (!fallback) return
|
||||
if (availableModels().some((model) => model.id === previewId())) return
|
||||
setPreviewId(fallback)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const models = filteredModels()
|
||||
if (models.some((model) => model.id === previewId())) return
|
||||
setPreviewId(models[0]?.id ?? "")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.requestAnimationFrame(() => searchInput?.focus())
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="compare-model-modal-scrim"
|
||||
role="presentation"
|
||||
onClick={props.onClose}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return
|
||||
props.onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="compare-model-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.label}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div data-slot="compare-model-modal-list">
|
||||
<label data-slot="compare-model-modal-search">
|
||||
<span data-slot="compare-model-modal-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInput}
|
||||
value={search()}
|
||||
placeholder="Search models"
|
||||
aria-label="Search models"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<div data-slot="compare-model-modal-results">
|
||||
<Show
|
||||
when={filteredModels().length > 0}
|
||||
fallback={
|
||||
<div data-slot="compare-model-modal-empty">
|
||||
<strong>No models found</strong>
|
||||
<span>Try another search.</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredModels()}>
|
||||
{(model) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="compare-model-modal-row"
|
||||
data-active={preview()?.id === model.id ? "true" : undefined}
|
||||
onPointerEnter={() => setPreviewId(model.id)}
|
||||
onFocus={() => setPreviewId(model.id)}
|
||||
onClick={() => props.onSelect(model)}
|
||||
>
|
||||
<span data-slot="compare-model-modal-row-main">
|
||||
<ModelAvatar model={model} size="tiny" />
|
||||
<span>{model.name}</span>
|
||||
</span>
|
||||
<Show when={isFreeModel(model)}>
|
||||
<span data-slot="compare-model-modal-badge">Free</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="compare-model-modal-divider" aria-hidden="true" />
|
||||
<Show when={preview()}>{(model) => <CompareModelDetail model={model()} />}</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompareModelDetail(props: { model: ModelCatalogEntry }) {
|
||||
return (
|
||||
<aside data-slot="compare-model-modal-detail">
|
||||
<header data-slot="compare-model-modal-detail-header">
|
||||
<ModelAvatar model={props.model} size="small" />
|
||||
<strong>{props.model.name}</strong>
|
||||
</header>
|
||||
<div data-slot="compare-model-modal-description">
|
||||
<p>
|
||||
{props.model.description ??
|
||||
`${props.model.name} is an AI model from ${formatCatalogLabName(props.model.lab)}.`}
|
||||
</p>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
<dl data-slot="compare-model-modal-facts">
|
||||
<CompareModelFact label="Release" value={formatCatalogDate(props.model.releaseDate)} />
|
||||
<CompareModelFact label="Context" value={formatCatalogLimit(props.model.limit?.context)} />
|
||||
<CompareModelFact label="Input" value={formatCatalogUnitPrice(props.model.cost?.input)} />
|
||||
<CompareModelFact label="Output" value={formatCatalogUnitPrice(props.model.cost?.output)} />
|
||||
<div data-slot="compare-model-modal-fact">
|
||||
<dt>Uptime</dt>
|
||||
<dd data-slot="compare-model-modal-uptime">
|
||||
<For each={uptimeBars}>{(bar) => <span data-active={bar < 7 ? "true" : undefined} />}</For>
|
||||
</dd>
|
||||
</div>
|
||||
<div data-slot="compare-model-modal-fact">
|
||||
<dt>URL</dt>
|
||||
<dd>
|
||||
<a href={modelHref(props.model)}>{formatModelUrl(props.model)}</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function CompareModelFact(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div data-slot="compare-model-modal-fact">
|
||||
<dt>{props.label}</dt>
|
||||
<dd>{props.value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroModelStack() {
|
||||
return (
|
||||
<span data-slot="compare-home-avatar-stack" aria-hidden="true">
|
||||
<For each={heroLabs}>
|
||||
{(lab) => (
|
||||
<span data-slot="compare-home-avatar-frame">
|
||||
<LabLogo lab={lab.lab} label={lab.label} size="large" />
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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} />
|
||||
}
|
||||
|
||||
function LabLogo(props: { lab: string; label: string; size: "large" | "small" | "tiny" }) {
|
||||
const iconId = () => getProviderIconId(props.lab)
|
||||
|
||||
return (
|
||||
<span data-slot="compare-home-avatar" data-lab={iconId()} data-size={props.size} aria-label={props.label}>
|
||||
<ProviderIcon aria-hidden="true" id={iconId()} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function buildComparisonCategories(models: ModelCatalogEntry[]): CompareCategory[] {
|
||||
return categoryTemplates.reduce<{ keys: Set<string>; categories: CompareCategory[] }>(
|
||||
(result, template, index) => {
|
||||
const candidates = categoryCandidates(template.kind, models)
|
||||
const pair = categoryPair(candidates, models, index, result.keys)
|
||||
if (!pair) return result
|
||||
result.keys.add(comparisonKey(pair.first, pair.second))
|
||||
const first = modelRefFromCatalog(pair.first)
|
||||
const second = modelRefFromCatalog(pair.second)
|
||||
result.categories.push({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
first,
|
||||
second,
|
||||
avatars: [first, second],
|
||||
})
|
||||
return result
|
||||
},
|
||||
{ keys: new Set(), categories: [] },
|
||||
).categories
|
||||
}
|
||||
|
||||
function categoryPair(
|
||||
candidates: ModelCatalogEntry[],
|
||||
fallback: ModelCatalogEntry[],
|
||||
offset: number,
|
||||
usedKeys: Set<string>,
|
||||
) {
|
||||
const pool = uniqueModels([...candidates, ...fallback.slice(offset), ...fallback])
|
||||
return pool
|
||||
.flatMap((first, firstIndex) =>
|
||||
pool.slice(firstIndex + 1).map((second) => ({
|
||||
first,
|
||||
second,
|
||||
key: comparisonKey(first, second),
|
||||
})),
|
||||
)
|
||||
.find((pair) => !usedKeys.has(pair.key))
|
||||
}
|
||||
|
||||
function categoryCandidates(kind: (typeof categoryTemplates)[number]["kind"], models: ModelCatalogEntry[]) {
|
||||
if (kind === "affordable")
|
||||
return models
|
||||
.filter((model) => model.cost)
|
||||
.toSorted(
|
||||
(a, b) => modelCost(a) - modelCost(b) || displayDateTime(b.releaseDate) - displayDateTime(a.releaseDate),
|
||||
)
|
||||
if (kind === "code") return models.filter((model) => model.toolCall || model.reasoning).toSorted(recentModelSort)
|
||||
if (kind === "image")
|
||||
return models
|
||||
.filter((model) => model.modalities.output.some((modality) => modality.toLowerCase().includes("image")))
|
||||
.toSorted(recentModelSort)
|
||||
return models.toSorted(recentModelSort)
|
||||
}
|
||||
|
||||
function uniqueModels(models: (ModelCatalogEntry | undefined)[]) {
|
||||
return models.reduce<{ keys: Set<string>; models: ModelCatalogEntry[] }>(
|
||||
(result, model) => {
|
||||
if (!model || result.keys.has(model.id)) return result
|
||||
result.keys.add(model.id)
|
||||
result.models.push(model)
|
||||
return result
|
||||
},
|
||||
{ keys: new Set(), models: [] },
|
||||
).models
|
||||
}
|
||||
|
||||
function recentModelSort(a: ModelCatalogEntry, b: ModelCatalogEntry) {
|
||||
return displayDateTime(b.releaseDate) - displayDateTime(a.releaseDate) || a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
function comparisonKey(first: ModelCatalogEntry, second: ModelCatalogEntry) {
|
||||
return [first.id, second.id].toSorted().join("|")
|
||||
}
|
||||
|
||||
function modelCost(model: ModelCatalogEntry) {
|
||||
return (model.cost?.input ?? Number.POSITIVE_INFINITY) + (model.cost?.output ?? Number.POSITIVE_INFINITY)
|
||||
}
|
||||
|
||||
function modelSearchText(model: ModelCatalogEntry) {
|
||||
return `${model.name} ${formatCatalogLabName(model.lab)} ${model.id}`.toLowerCase()
|
||||
}
|
||||
|
||||
function isFreeModel(model: ModelCatalogEntry) {
|
||||
return model.cost?.input === 0 && model.cost.output === 0
|
||||
}
|
||||
|
||||
function modelHref(model: ModelCatalogEntry) {
|
||||
return `${import.meta.env.BASE_URL}${model.lab}/${model.slug}`
|
||||
}
|
||||
|
||||
function formatModelUrl(model: ModelCatalogEntry) {
|
||||
return `.../${model.slug}`
|
||||
}
|
||||
|
||||
function formatCatalogLimit(value: number | undefined) {
|
||||
return value === undefined ? "Unknown" : formatTokens(value)
|
||||
}
|
||||
|
||||
function formatCatalogUnitPrice(value: number | undefined) {
|
||||
if (value === undefined) return "Unknown"
|
||||
return `${formatModelPrice(value)} / 1M`
|
||||
}
|
||||
|
||||
function formatModelPrice(value: number) {
|
||||
if (value > 0 && value < 0.01) return `$${value.toFixed(4)}`
|
||||
return formatMoney(value)
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
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 `$${value.toFixed(value >= 10 ? 0 : 2)}`
|
||||
}
|
||||
|
||||
function formatCatalogDate(value: string | undefined) {
|
||||
if (!value) return "Unknown"
|
||||
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
|
||||
if (!match) return value
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
month: match[2] ? "short" : undefined,
|
||||
day: match[3] ? "numeric" : undefined,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(Date.UTC(Number(match[1]), match[2] ? Number(match[2]) - 1 : 0, match[3] ? Number(match[3]) : 1)))
|
||||
}
|
||||
|
||||
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 trimNumber(value: number, digits: number) {
|
||||
return Number(value.toFixed(digits)).toLocaleString("en")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getProviderIconId(provider: string) {
|
||||
const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
if (id === "moonshot") return "moonshotai"
|
||||
if (id === "zhipu") return "zhipuai"
|
||||
return id
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ import { localizedUrl } from "../lib/language"
|
||||
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||
import { SectionHeading } from "./section-heading"
|
||||
import { setStatsPageCacheHeaders } from "./stats-cache"
|
||||
import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards"
|
||||
import {
|
||||
applyThemePreference,
|
||||
Footer,
|
||||
@@ -48,6 +49,12 @@ import {
|
||||
const products = ["All Users", "Zen", "Go"] as const
|
||||
const tokenProducts = ["Zen", "Go"] as const
|
||||
const ranges = ["1D", "1W", "2W", "1M", "2M"] as const
|
||||
const comparisonPairIndexes = [
|
||||
[0, 1, "Top two by recent usage"],
|
||||
[0, 2, "Leader vs challenger"],
|
||||
[1, 2, "Adjacent leaderboard pair"],
|
||||
[2, 3, "Top model alternative"],
|
||||
] as const
|
||||
const statsUnfurlPath = "banner.jpg"
|
||||
const usageColors = [
|
||||
"#ed6aff",
|
||||
@@ -183,11 +190,20 @@ export default function StatsHome() {
|
||||
<CacheRatioSection data={stats().cacheRatio} />
|
||||
<MarketShareSection data={stats().market} />
|
||||
<GeoBreakdownSection data={stats().country} />
|
||||
<ComparisonCardsSection
|
||||
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
|
||||
title="Model Comparisons"
|
||||
description="Popular model pairs from the leaderboard."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Footer themePreference={themePreference()} onThemePreferenceChange={updateThemePreference} />
|
||||
<Footer
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
bridge={{ href: "#model-comparison", label: "MODEL COMPARISONS" }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
@@ -1969,6 +1985,26 @@ function formatSessionCost(value: number) {
|
||||
return `$${value.toFixed(4)}`
|
||||
}
|
||||
|
||||
function homeComparisonPairs(leaderboard: LeaderboardEntry[]) {
|
||||
return uniqueComparisonPairs(
|
||||
comparisonPairIndexes.flatMap(([firstIndex, secondIndex, detail]) => {
|
||||
const first = leaderboard[firstIndex]
|
||||
const second = leaderboard[secondIndex]
|
||||
return first && second ? [{ first: leaderboardRef(first), second: leaderboardRef(second), detail }] : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function leaderboardRef(entry: LeaderboardEntry): ComparisonModelRef {
|
||||
return {
|
||||
name: entry.model,
|
||||
lab: entry.provider,
|
||||
slug: modelSlug(entry.model),
|
||||
labName: entry.author,
|
||||
metric: `#${entry.rank} / ${formatBillions(entry.tokens)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function modelSlug(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
@@ -216,6 +216,7 @@ export function Footer(props: {
|
||||
themePreference: ThemePreference
|
||||
onThemePreferenceChange: (preference: ThemePreference) => void
|
||||
links?: readonly HeaderLink[]
|
||||
bridge?: HeaderLink | null
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
@@ -241,10 +242,14 @@ export function Footer(props: {
|
||||
{ href: githubLink.href, label: i18n.t("header.github") },
|
||||
{ href: "https://www.youtube.com/@anomaly-co", label: i18n.t("footer.youtube") },
|
||||
]
|
||||
const bridge = () =>
|
||||
props.bridge === undefined
|
||||
? { href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown").toUpperCase() }
|
||||
: props.bridge
|
||||
|
||||
return (
|
||||
<footer data-component="footer">
|
||||
<SectionBridge label={i18n.t("nav.geoBreakdown").toUpperCase()} href="#geo-breakdown" />
|
||||
<Show when={bridge()}>{(link) => <SectionBridge label={link().label} href={link().href} />}</Show>
|
||||
<div data-slot="footer-grid">
|
||||
<a data-slot="footer-mark" href="https://opencode.ai" aria-label={i18n.t("footer.homeAria")}>
|
||||
<OpenCodeMark />
|
||||
|
||||
Reference in New Issue
Block a user