TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import * as React from "react";2import Link from "next/link";3import { Check, Minus } from "lucide-react";4import type { ModelCapabilities, PolyModel } from "@/lib/ai/core/types";5import { ProviderIcon } from "@/components/brand/provider-icon";6import { PROVIDERS } from "@/lib/client/providers";7import { deriveBadges, lifecycleStatus, speedTier, type BadgeContext } from "@/lib/models/badges";8import { formatContext, formatIsoDate, formatPrice } from "@/lib/models/format";9import { PARAM_DEFS } from "@/lib/models/params";10import { BadgeChips, LifecycleChip } from "./badge-chips";11import { Meter } from "./meter";12import { cn } from "@/lib/utils";1314/**15 * Side-by-side comparison of 2–4 models. Server-safe (no hooks): used by the public16 * `/compare/[slug]` page and by `/app/models/compare`. Desktop = grid table; phones = stacked17 * spec cards (one per row) so nothing overflows horizontally.18 */19export const CAPABILITY_LABELS: Record<keyof ModelCapabilities, string> = {20 text: "Text",21 vision: "Vision (images)",22 reasoning: "Reasoning",23 tools: "Tool calling",24 structuredOutput: "Structured output",25 webSearch: "Web search",26 files: "File / PDF input",27 streaming: "Streaming",28 audioInput: "Audio input",29 audioOutput: "Audio output",30 imageGeneration: "Image generation",31 video: "Video input",32};3334interface SpecRow {35 key: string;36 label: string;37 hint?: string;38 value: (m: PolyModel) => React.ReactNode;39 /** Numeric magnitude for the meter (null = no bar). */40 bar?: (m: PolyModel) => number | null;41}4243function longTier(m: PolyModel): string {44 const lc = m.pricing?.longContext;45 if (!lc) return "—";46 return `> ${formatContext(lc.thresholdTokens)}: ${formatPrice(lc.inputPerMillion)} / ${formatPrice(lc.outputPerMillion)}`;47}4849function metaStr(m: PolyModel, key: string): string {50 const v = m.metadata?.[key];51 return typeof v === "string" && v ? v : "";52}5354export function CompareTable({ models, ctx, hrefFor, connected, className }: { models: PolyModel[]; ctx: BadgeContext; hrefFor?: (m: PolyModel) => string | undefined; connected?: (m: PolyModel) => boolean; className?: string }) {55 const rows: SpecRow[] = [56 { key: "provider", label: "Provider", value: (m) => PROVIDERS[m.provider].name },57 { key: "family", label: "Family", value: (m) => m.family ?? "—" },58 { key: "status", label: "Status", value: (m) => <LifecycleChip lifecycle={lifecycleStatus(m, { ctx, connected: connected?.(m) })} /> },59 { key: "context", label: "Context window", value: (m) => `${formatContext(m.limits?.contextTokens)} tokens`, bar: (m) => m.limits?.contextTokens ?? null },60 { key: "maxOut", label: "Max output", value: (m) => `${formatContext(m.limits?.maxOutputTokens)} tokens`, bar: (m) => m.limits?.maxOutputTokens ?? null },61 { key: "in", label: "Input price", hint: "USD per 1M tokens — shorter is cheaper", value: (m) => formatPrice(m.pricing?.inputPerMillion), bar: (m) => m.pricing?.inputPerMillion ?? null },62 { key: "out", label: "Output price", hint: "USD per 1M tokens — shorter is cheaper", value: (m) => formatPrice(m.pricing?.outputPerMillion), bar: (m) => m.pricing?.outputPerMillion ?? null },63 { key: "cached", label: "Cached input", value: (m) => formatPrice(m.pricing?.cachedInputPerMillion) },64 { key: "longTier", label: "Long-context tier", value: longTier },65 { key: "speed", label: "Speed tier", value: (m) => ({ fast: "Fast", standard: "Standard", frontier: "Frontier" })[speedTier(m, ctx)] },66 { key: "effort", label: "Reasoning effort", value: (m) => (m.parameters.reasoningEffort && m.parameters.reasoningEffortLevels?.length ? m.parameters.reasoningEffortLevels.join(" · ") : m.capabilities.reasoning ? "always on" : "—") },67 { key: "budget", label: "Thinking budget", value: (m) => (m.parameters.thinkingBudgetRange ? `${formatContext(m.parameters.thinkingBudgetRange.min)}–${formatContext(m.parameters.thinkingBudgetRange.max)}` : "—") },68 { key: "cutoff", label: "Knowledge cutoff", hint: "Only when the provider publishes it", value: (m) => metaStr(m, "knowledgeCutoff") || "—" },69 { key: "release", label: "Release date", hint: "Only when the provider publishes it", value: (m) => (metaStr(m, "releaseDate") ? formatIsoDate(metaStr(m, "releaseDate")) : "—") },70 { key: "badges", label: "Badges", value: (m) => <BadgeChips badges={deriveBadges(m, ctx)} max={5} /> },71 ];72 const colStyle: React.CSSProperties = { gridTemplateColumns: `150px repeat(${models.length}, minmax(0, 1fr))` };73 const max = (row: SpecRow) => Math.max(0, ...models.map((m) => row.bar?.(m) ?? 0));7475 return (76 <div className={cn("space-y-8", className)}>77 {/* Header cards */}78 <div className={cn("grid gap-3", models.length >= 3 ? "grid-cols-2 md:grid-cols-4" : "grid-cols-2")}>79 {models.map((m) => {80 const href = hrefFor?.(m);81 const inner = (82 <>83 <span className="flex items-center gap-2">84 <ProviderIcon provider={m.provider} size={18} />85 <span className="text-[11px] font-medium uppercase tracking-wide text-fg-subtle">{PROVIDERS[m.provider].shortName}</span>86 </span>87 <span className="mt-2 block text-balance text-[15px] font-semibold leading-5 sm:text-base">{m.displayName}</span>88 <span className="mt-0.5 block truncate font-mono text-[11px] text-fg-subtle">{m.id}</span>89 </>90 );91 return href ? (92 <Link key={m.key} href={href} className="panel block min-w-0 p-3 transition-colors hover:bg-bg-muted sm:p-4">93 {inner}94 </Link>95 ) : (96 <div key={m.key} className="panel min-w-0 p-3 sm:p-4">97 {inner}98 </div>99 );100 })}101 </div>102103 {/* Desktop grid */}104 <section aria-label="Specifications" className="hidden md:block">105 <div className="grid items-center gap-x-4 border-b border-border pb-2 text-[11px] font-medium uppercase tracking-wide text-fg-subtle" style={colStyle}>106 <span>Spec</span>107 {models.map((m) => (108 <span key={m.key} className="flex items-center gap-1.5 truncate">109 <ProviderIcon provider={m.provider} size={12} /> {m.displayName}110 </span>111 ))}112 </div>113 {rows.map((row) => {114 const mx = row.bar ? max(row) : 0;115 return (116 <div key={row.key} className="grid items-start gap-x-4 border-b border-hairline py-2.5 text-[13px]" style={colStyle}>117 <div>118 <div className="font-medium text-fg-muted">{row.label}</div>119 {row.hint ? <div className="text-[11px] text-fg-subtle">{row.hint}</div> : null}120 </div>121 {models.map((m) => (122 <div key={m.key} className="min-w-0 tabular-nums">123 <div className="truncate">{row.value(m)}</div>124 {row.bar ? <Meter value={row.bar(m)} max={mx} className="mt-1.5 w-full max-w-[220px]" label={`${row.label}: ${typeof row.value(m) === "string" ? row.value(m) : ""}`} /> : null}125 </div>126 ))}127 </div>128 );129 })}130 </section>131132 {/* Mobile stacked */}133 <section aria-label="Specifications" className="space-y-3 md:hidden">134 {rows.map((row) => {135 const mx = row.bar ? max(row) : 0;136 return (137 <div key={row.key} className="panel p-3">138 <div className="text-[12px] font-medium text-fg-muted">{row.label}</div>139 {row.hint ? <div className="text-[11px] text-fg-subtle">{row.hint}</div> : null}140 <ul className="mt-2 space-y-2">141 {models.map((m) => (142 <li key={m.key} className="min-w-0">143 <div className="flex items-center justify-between gap-3 text-[13px]">144 <span className="flex min-w-0 items-center gap-1.5 text-fg-muted">145 <ProviderIcon provider={m.provider} size={13} />146 <span className="truncate">{m.displayName}</span>147 </span>148 <span className="shrink-0 tabular-nums">{row.value(m)}</span>149 </div>150 {row.bar ? <Meter value={row.bar(m)} max={mx} className="mt-1" /> : null}151 </li>152 ))}153 </ul>154 </div>155 );156 })}157 </section>158159 <Matrix title="Capabilities" models={models} rows={(Object.keys(CAPABILITY_LABELS) as (keyof ModelCapabilities)[]).map((k) => ({ key: k, label: CAPABILITY_LABELS[k], on: (m: PolyModel) => Boolean(m.capabilities[k]) }))} />160 <Matrix title="Supported parameters" models={models} rows={PARAM_DEFS.map((d) => ({ key: d.key, label: d.label, on: (m: PolyModel) => d.supports(m), detail: (m: PolyModel) => (d.supports(m) ? d.detail?.(m) ?? null : null) }))} />161 </div>162 );163}164165function Matrix({ title, models, rows }: { title: string; models: PolyModel[]; rows: { key: string; label: string; on: (m: PolyModel) => boolean; detail?: (m: PolyModel) => string | null }[] }) {166 const colStyle: React.CSSProperties = { gridTemplateColumns: `150px repeat(${models.length}, minmax(0, 1fr))` };167 const mobileStyle: React.CSSProperties = { gridTemplateColumns: `minmax(0, 1fr) repeat(${models.length}, 40px)` };168 return (169 <section aria-label={title}>170 <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">{title}</h3>171 {/* Desktop */}172 <div className="hidden md:block">173 <div className="grid gap-x-4 border-b border-border pb-2 text-[11px] font-medium uppercase tracking-wide text-fg-subtle" style={colStyle}>174 <span />175 {models.map((m) => (176 <span key={m.key} className="truncate">177 {m.displayName}178 </span>179 ))}180 </div>181 {rows.map((r) => (182 <div key={r.key} className="grid items-center gap-x-4 border-b border-hairline py-2 text-[13px]" style={colStyle}>183 <span className="text-fg-muted">{r.label}</span>184 {models.map((m) => (185 <span key={m.key} className="flex min-w-0 items-center gap-2">186 <Mark on={r.on(m)} />187 {r.detail?.(m) ? <span className="truncate font-mono text-[11px] text-fg-subtle">{r.detail(m)}</span> : null}188 </span>189 ))}190 </div>191 ))}192 </div>193 {/* Mobile: compact check grid with icon headers */}194 <div className="panel p-3 md:hidden">195 <div className="grid items-center gap-x-1 border-b border-hairline pb-2" style={mobileStyle}>196 <span />197 {models.map((m) => (198 <span key={m.key} className="flex justify-center" title={m.displayName}>199 <ProviderIcon provider={m.provider} size={14} />200 </span>201 ))}202 </div>203 {rows.map((r) => (204 <div key={r.key} className="grid items-center gap-x-1 border-b border-hairline py-2 text-[13px] last:border-b-0" style={mobileStyle}>205 <span className="min-w-0 truncate text-fg-muted">{r.label}</span>206 {models.map((m) => (207 <span key={m.key} className="flex justify-center">208 <Mark on={r.on(m)} />209 </span>210 ))}211 </div>212 ))}213 <p className="mt-2 text-[10.5px] text-fg-subtle">Columns: {models.map((m) => m.displayName).join(" · ")}</p>214 </div>215 </section>216 );217}218219function Mark({ on }: { on: boolean }) {220 return on ? <Check className="size-4 text-success" aria-label="yes" /> : <Minus className="size-4 text-border-strong" aria-label="no" />;221}222