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:
parent
2347f4d7b4
commit
687dbba6a3
150 changed files with 8987 additions and 772 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
88
packages/stats/app/src/routes/compare-cards.tsx
Normal file
88
packages/stats/app/src/routes/compare-cards.tsx
Normal file
|
|
@ -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)}`
|
||||
}
|
||||
83
packages/stats/app/src/routes/compare-selector.tsx
Normal file
83
packages/stats/app/src/routes/compare-selector.tsx
Normal file
|
|
@ -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 ?? ""
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
642
packages/stats/app/src/routes/compare/index.tsx
Normal file
642
packages/stats/app/src/routes/compare/index.tsx
Normal file
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Client } from "@planetscale/database"
|
||||
import { Effect } from "effect"
|
||||
import { Resource } from "sst/resource"
|
||||
import { DatabaseError } from "../database"
|
||||
import type { GeoStatMetric } from "./geo"
|
||||
import type { ModelStatMetric } from "./model"
|
||||
import { ModelStatRepo, type ModelStatMetric } from "./model"
|
||||
import type { ProviderStatMetric } from "./provider"
|
||||
|
||||
export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise"
|
||||
|
|
@ -81,6 +82,23 @@ export type StatsLabData = {
|
|||
usage: ModelUsagePoint[]
|
||||
models: LabUsageModelEntry[]
|
||||
}
|
||||
export type StatsModelComparisonEntry = {
|
||||
updatedAt: string | null
|
||||
model: string
|
||||
slug: string
|
||||
provider: string
|
||||
author: string
|
||||
rank: number | null
|
||||
previousRank: number | null
|
||||
totalModels: number
|
||||
tokenShare: number
|
||||
tokenChange: number
|
||||
totals: StatsModelData["totals"]
|
||||
}
|
||||
export type StatsModelComparisonData = {
|
||||
updatedAt: string | null
|
||||
models: [StatsModelComparisonEntry | null, StatsModelComparisonEntry | null]
|
||||
}
|
||||
export type StatsHomeData = {
|
||||
updatedAt: string | null
|
||||
usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
|
||||
|
|
@ -271,6 +289,27 @@ function dateValue(value: unknown) {
|
|||
return value instanceof Date ? value : new Date(stringValue(value))
|
||||
}
|
||||
|
||||
export const getStatsModelComparisonData: (
|
||||
firstProvider: string,
|
||||
firstModel: string,
|
||||
secondProvider: string,
|
||||
secondModel: string,
|
||||
) => Effect.Effect<StatsModelComparisonData, DatabaseError, ModelStatRepo> = Effect.fn("StatsModelComparison.getData")(
|
||||
function* (firstProvider, firstModel, secondProvider, secondModel) {
|
||||
const modelStats = yield* ModelStatRepo
|
||||
const rows = yield* modelStats.listDaily()
|
||||
const first = toComparisonEntry(buildStatsModelData(firstModel, rows, [], firstProvider))
|
||||
const second = toComparisonEntry(buildStatsModelData(secondModel, rows, [], secondProvider))
|
||||
const latest = [first?.updatedAt, second?.updatedAt]
|
||||
.flatMap((value) => (value ? [dateTime(value)] : []))
|
||||
.toSorted((a, b) => b - a)[0]
|
||||
return {
|
||||
updatedAt: latest === undefined ? null : new Date(latest).toISOString(),
|
||||
models: [first, second],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function buildStatsHomeData(
|
||||
modelRows: ModelStatMetric[],
|
||||
providerRows: ProviderStatMetric[],
|
||||
|
|
@ -448,6 +487,23 @@ function buildStatsLabData(providerParam: string, modelRows: ModelStatMetric[]):
|
|||
}
|
||||
}
|
||||
|
||||
function toComparisonEntry(data: StatsModelData | null): StatsModelComparisonEntry | null {
|
||||
if (!data) return null
|
||||
return {
|
||||
updatedAt: data.updatedAt,
|
||||
model: data.model,
|
||||
slug: data.slug,
|
||||
provider: data.provider,
|
||||
author: data.author,
|
||||
rank: data.rank,
|
||||
previousRank: data.previousRank,
|
||||
totalModels: data.totalModels,
|
||||
tokenShare: data.tokenShare,
|
||||
tokenChange: data.tokenChange,
|
||||
totals: data.totals,
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStatsHomeData(): StatsHomeData {
|
||||
return {
|
||||
updatedAt: null,
|
||||
|
|
@ -646,8 +702,8 @@ function buildModelTokenMix(aggregate: ModelAggregate): ModelMixEntry[] {
|
|||
}
|
||||
|
||||
function buildModelPeers(peers: ModelAggregate[], rank: number, totalTokens: number): ModelPeerEntry[] {
|
||||
const start = Math.max(0, Math.min(rank - 4, Math.max(peers.length - 7, 0)))
|
||||
return peers.slice(start, start + 7).map((item, index) => ({
|
||||
const start = Math.max(0, Math.min(rank - 5, Math.max(peers.length - 10, 0)))
|
||||
return peers.slice(start, start + 10).map((item, index) => ({
|
||||
model: item.model,
|
||||
provider: item.provider,
|
||||
author: formatProvider(item.provider),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.14",
|
||||
"version": "1.17.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue