web D2: intelligence surfaces — frontier, price terminal, calculator, run-locally, find-a-model, open, pulse, providers 2.0, hardware 2.0, typed listings
- /frontier flagship (SectionNav; latest majors, benchmark/price/context/open-weight/efficiency (Pareto scatter)/agentic/multimodal frontiers, movements ticker; API methodology per section; OG image)
- /prices Price History Terminal on TerminalLayout: rail filters (provider · org · family · modality · window · sort incl. cheapest_frontier, URL state), AI Price Index strip + InteractiveLineChart (5 medians, linear/log, sample sizes in tooltip, ≥2-snapshot honesty), distribution histogram, cheapest-frontier strips, movers with %, listings/delistings, offers table with lazy per-row price history (client fetch); OG image
- /calculator (client, same-origin /cost + /cost/context): every deployment side by side, cheapest bold ≠ verdict, price rows used + source; Context-cost tab with 128K/200K/1M/2M presets; URL state via replaceState
- /run-locally Local AI Explorer (rail form: platform · memory presets Apple/NVIDIA/AMD + custom · GPU count · quant · context · batch · use case · openness; ?hardware=<slug> mode via /hardware/{slug}/fit; fit breakdown, artifact rows with observed/estimated tags, multi-GPU note, Estimated everywhere)
- /find-a-model Model Finder (guided rail form → /find-a-model: why chips, observed dimensions, estimated fit, deployments in API mode, Compare/Watch; rules from the API; no single winner)
- /open Open Model Frontier (summary strip with /methodology openness definitions, rail filters, licence permission glyphs, ranks, providers, 64 GB@4bit / 128 GB@8bit estimates, openness explorer: category bar + licence-permission matrix; OG image)
- /pulse Ecosystem Pulse (DataStrip of counters with definitions, 7/30/90 windows, new benchmark leaders, price changes, ≥1M-context models, stats-history sparklines when ≥2 days; OG image)
- /providers 2.0 (TerminalLayout table: models · orgs · input/output distribution range bars on a shared log axis · Δ price 30 d · ± models 30 d · features) and dedicated /providers/[slug] (aggregate strip, deployments with expandable StepChart history, added/removed, price events, features & native price keys, organizations covered, attributes with evidence, JSON-LD, OG)
- /hardware 2.0 (rail: kind · manufacturer · memory ≥; precision/interconnect/TDP columns honest dashes), /hardware/frontier (memory & bandwidth step charts by release + per-manufacturer tables connected to /hardware/{slug}/fit counts) and dedicated /hardware/[slug] (KeyValue specs with evidence, fit table with quant/context/configuration chips, multi-unit note, JSON-LD, OG)
- Typed listings on a new IntelListing (per-page enrichment, honest empty states with connector names): /agents (new), /tools, /datasets (used-to-train + papers from relations, access), /frameworks (canonical kind, stars sparkline from claim history)
- Shared lib: D2 blocks in types.ts / api.ts (`intel`) / client-api.ts (`clientIntel`); reuses the D1 Deployment/Group/LeaderboardRow/LicenseInfo/Pareto shapes
- QA: qa/d2.mjs — 33 routes × 320…1920 × dark/light (status, console errors, overflow, Estimated label, price-index chart or note), flows (offer history, mobile filter sheet, calculator tabs + URL, provider history, run-locally submit, frontier scatter), OG images
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
37 changed files +4,974 −255
added
apps/web/qa/d2.mjs
+209 −0
@@ -0,0 +1,209 @@ | ||
| 1 | +/** | |
| 2 | + * D2 QA sweep — intelligence surfaces (frontier · prices · calculator · run-locally · find-a-model · open · pulse · providers · hardware · | |
| 3 | + * agents · tools · datasets · frameworks) at 320 · 360 · 375 · 390 · 430 · 768 · 1366 · 1440 · 1920, dark + light. | |
| 4 | + * Asserts: HTTP status (404 for unknown slugs), zero console errors, no horizontal overflow, an `Estimated` label on every fit page, | |
| 5 | + * the price-index chart or its honest note on /prices, then a few flows (offer history expansion, calculator result + context tab, | |
| 6 | + * provider history step chart, mobile filter sheet). Slugs are discovered live from the API. | |
| 7 | + * Run: node qa/d2.mjs [BASE_URL] [API_URL] (defaults http://localhost:8342, http://127.0.0.1:8332) | |
| 8 | + */ | |
| 9 | +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; | |
| 10 | +import { mkdirSync } from 'node:fs'; | |
| 11 | + | |
| 12 | +const BASE = process.argv[2] ?? 'http://localhost:8342'; | |
| 13 | +const API = process.argv[3] ?? 'http://127.0.0.1:8332'; | |
| 14 | +const OUT = new URL('./screens/d2/', import.meta.url).pathname; | |
| 15 | +mkdirSync(OUT, { recursive: true }); | |
| 16 | +const QUICK = process.env.QUICK === '1'; | |
| 17 | +const WIDTHS = process.env.WIDTHS ? process.env.WIDTHS.split(',').map(Number) : QUICK ? [390, 1440] : [320, 360, 375, 390, 430, 768, 1366, 1440, 1920]; | |
| 18 | +const THEMES = ['dark', 'light']; | |
| 19 | + | |
| 20 | +const j = async (p) => (await fetch(`${API}/api/v1${p}`)).json(); | |
| 21 | +const providers = await j('/providers').catch(() => ({ items: [] })); | |
| 22 | +const topProvider = (providers.items ?? []).slice().sort((a, b) => Number(b.model_count) - Number(a.model_count))[0]?.slug ?? 'openrouter-api'; | |
| 23 | +const hw = await j('/hardware?limit=50&sort=memory').catch(() => ({ items: [] })); | |
| 24 | +const hwSlug = (hw.items ?? []).find((h) => h.slug === 'apple-m4-max')?.slug ?? hw.items?.[0]?.slug ?? 'apple-m4-max'; | |
| 25 | +const sug = await j('/search/suggest?q=claude').catch(() => ({ items: [] })); | |
| 26 | +const model = (sug.items ?? []).find((s) => s.entity_type === 'model')?.slug ?? 'claude-opus-5'; | |
| 27 | +console.log(`slugs: provider=${topProvider} hardware=${hwSlug} model=${model}`); | |
| 28 | + | |
| 29 | +/** [path, expectedStatus, checks[]] — checks: 'estimated' (an Estimated label exists), 'index' (price chart or honest note). */ | |
| 30 | +const PAGES = [ | |
| 31 | + ['/frontier', 200, []], | |
| 32 | + ['/prices', 200, ['index']], | |
| 33 | + ['/prices?sort=cheapest_frontier&days=30', 200, ['index']], | |
| 34 | + ['/calculator', 200, []], | |
| 35 | + [`/calculator?model=${model}&input_tokens=2000&output_tokens=800&requests_per_day=500&cached=20`, 200, []], | |
| 36 | + [`/calculator?model=${model}&tab=context&tokens=200000`, 200, []], | |
| 37 | + ['/run-locally', 200, ['estimated']], | |
| 38 | + ['/run-locally?memory_gb=64&quant=4bit', 200, ['estimated']], | |
| 39 | + ['/run-locally?memory_gb=24&gpu_count=2&quant=8bit&platform=nvidia&use_case=coding', 200, ['estimated']], | |
| 40 | + [`/run-locally?hardware=${hwSlug}`, 200, ['estimated']], | |
| 41 | + ['/find-a-model', 200, []], | |
| 42 | + ['/find-a-model?use_case=coding&deployment=local&memory_gb=64', 200, ['estimated']], | |
| 43 | + ['/find-a-model?use_case=chat&deployment=api&max_output_price=5', 200, []], | |
| 44 | + ['/open', 200, ['estimated']], | |
| 45 | + ['/open?license=Apache-2.0&min_params=7B&sort=params', 200, ['estimated']], | |
| 46 | + ['/pulse', 200, []], | |
| 47 | + ['/pulse?days=30', 200, []], | |
| 48 | + ['/providers', 200, []], | |
| 49 | + ['/providers?feature=batch&sort=median_input', 200, []], | |
| 50 | + [`/providers/${topProvider}`, 200, []], | |
| 51 | + ['/providers/does-not-exist', 404, []], | |
| 52 | + ['/hardware', 200, []], | |
| 53 | + ['/hardware?kind=gpu&min_memory=64', 200, []], | |
| 54 | + ['/hardware/frontier', 200, ['estimated']], | |
| 55 | + [`/hardware/${hwSlug}`, 200, ['estimated']], | |
| 56 | + [`/hardware/${hwSlug}?quant=8bit&context=32768`, 200, ['estimated']], | |
| 57 | + ['/hardware/does-not-exist', 404, []], | |
| 58 | + ['/agents', 200, []], | |
| 59 | + ['/tools', 200, []], | |
| 60 | + ['/datasets', 200, []], | |
| 61 | + ['/frameworks', 200, []], | |
| 62 | + ['/frameworks?sort=stars', 200, []], | |
| 63 | +]; | |
| 64 | +const OG = ['/frontier/opengraph-image', '/prices/opengraph-image', '/open/opengraph-image', '/pulse/opengraph-image', `/providers/${topProvider}/opengraph-image`, `/hardware/${hwSlug}/opengraph-image`]; | |
| 65 | + | |
| 66 | +// 404 favicon noise and 429 on the fire-and-forget view beacon (the API allows 1 view/s per IP; the sweep is much faster) are not page errors | |
| 67 | +const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of (404|429)/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e))); | |
| 68 | +const smallTargets = () => { | |
| 69 | + const root = document.querySelector('[role="dialog"]') ?? document; | |
| 70 | + return [...root.querySelectorAll('button, [role="button"], input:not([type=hidden]):not([type=radio]):not([type=checkbox]), select, a[href]')] | |
| 71 | + .filter((el) => { | |
| 72 | + const r = el.getBoundingClientRect(); | |
| 73 | + if (r.width === 0 || r.height === 0) return false; | |
| 74 | + const cs = getComputedStyle(el); | |
| 75 | + if (cs.visibility === 'hidden' || cs.display === 'none') return false; | |
| 76 | + if (el.closest('.sr-only, p, dd, .prose-atlas, td, th, .kv, [data-ticker], .data-table, footer, header, [data-section-nav], .hint, li')) return false; | |
| 77 | + if (el.tagName === 'A') { | |
| 78 | + const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]'); | |
| 79 | + if (!inNav && el.textContent.trim().length > 0) return false; | |
| 80 | + } | |
| 81 | + return Math.min(r.width, r.height) < 44; | |
| 82 | + }) | |
| 83 | + .map((el) => `${el.tagName.toLowerCase()}.${[...el.classList].slice(0, 2).join('.')} "${(el.getAttribute('aria-label') || el.textContent || el.getAttribute('name') || '').trim().slice(0, 20)}" ${Math.round(el.getBoundingClientRect().width)}×${Math.round(el.getBoundingClientRect().height)}`); | |
| 84 | +}; | |
| 85 | +const slug = (p) => p.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '').slice(0, 80) || 'home'; | |
| 86 | + | |
| 87 | +const browser = await chromium.launch(); | |
| 88 | +let failures = 0; | |
| 89 | +let smallTotal = 0; | |
| 90 | + | |
| 91 | +for (const theme of THEMES) { | |
| 92 | + for (const width of WIDTHS) { | |
| 93 | + const mobile = width < 768; | |
| 94 | + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); | |
| 95 | + await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme); | |
| 96 | + const page = await ctx.newPage(); | |
| 97 | + for (const [path, expected, checks] of PAGES) { | |
| 98 | + // full matrix in dark; light at the four reference widths to keep the sweep under control | |
| 99 | + if (theme === 'light' && ![390, 430, 768, 1440].includes(width) && !QUICK && !process.env.WIDTHS) continue; | |
| 100 | + const errors = []; | |
| 101 | + const onErr = (e) => errors.push(String(e)); | |
| 102 | + const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); }; | |
| 103 | + page.on('pageerror', onErr); | |
| 104 | + page.on('console', onCon); | |
| 105 | + const t0 = Date.now(); | |
| 106 | + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); | |
| 107 | + if (/calculator\?model/.test(path)) await page.waitForSelector('[data-cost-row], [data-context-row], [data-calculator] p', { timeout: 15000 }).catch(() => undefined); | |
| 108 | + await page.waitForTimeout(500); | |
| 109 | + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); | |
| 110 | + const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null); | |
| 111 | + const est = checks.includes('estimated') ? await page.evaluate(() => !![...document.querySelectorAll('a,span')].find((el) => /^estimated$/i.test(el.textContent.trim()))).catch(() => false) : true; | |
| 112 | + const idx = checks.includes('index') ? await page.evaluate(() => !!document.querySelector('[data-price-index-chart], [data-price-index-note]')).catch(() => false) : true; | |
| 113 | + const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : []; | |
| 114 | + smallTotal += small.length; | |
| 115 | + const status = res.status(); | |
| 116 | + const filtered = filterErrors(errors, expected); | |
| 117 | + const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme && est && idx; | |
| 118 | + if (!ok) failures++; | |
| 119 | + console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${String(width).padStart(4)} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length}${checks.includes('estimated') ? ` estimated=${est}` : ''}${checks.includes('index') ? ` index=${idx}` : ''}${small.length ? ` small=${small.length}` : ''} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}${small.length ? ' :: ' + small.slice(0, 2).join(' | ') : ''}`); | |
| 120 | + if (theme === 'dark' || width === 390 || width === 1440) await page.screenshot({ path: `${OUT}${theme}-${width}-${slug(path)}.png`, fullPage: false }).catch(() => undefined); | |
| 121 | + page.off('pageerror', onErr); | |
| 122 | + page.off('console', onCon); | |
| 123 | + } | |
| 124 | + await ctx.close(); | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +// ---------------------------------------------------------------------------------------------------------------- flows | |
| 129 | +console.log('\n— flows —'); | |
| 130 | +{ | |
| 131 | + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' }); | |
| 132 | + await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark')); | |
| 133 | + const page = await ctx.newPage(); | |
| 134 | + const errors = []; | |
| 135 | + page.on('pageerror', (e) => errors.push(String(e))); | |
| 136 | + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); | |
| 137 | + const step = (ok, label) => { if (!ok) failures++; console.log(`${ok ? 'OK ' : 'FAIL'} flow ${label}`); }; | |
| 138 | + | |
| 139 | + // prices: expand the first offer row → history row appears (sparklines or the honest "no change" note) | |
| 140 | + await page.goto(`${BASE}/prices`, { waitUntil: 'networkidle' }); | |
| 141 | + await page.locator('[data-expand-history]').first().click(); | |
| 142 | + const hist = await page.waitForSelector('[data-offer-history]', { timeout: 15000 }).catch(() => null); | |
| 143 | + await page.waitForTimeout(1500); | |
| 144 | + const histText = hist ? await page.locator('[data-offer-history]').first().innerText() : ''; | |
| 145 | + step(!!hist && !/Loading/.test(histText), `prices: offer row expands to history (${histText.replace(/\s+/g, ' ').slice(0, 60)}…)`); | |
| 146 | + await page.screenshot({ path: `${OUT}flow-prices-history.png` }); | |
| 147 | + | |
| 148 | + // prices mobile: filter sheet opens with the rail form | |
| 149 | + await page.setViewportSize({ width: 390, height: 844 }); | |
| 150 | + await page.goto(`${BASE}/prices`, { waitUntil: 'networkidle' }); | |
| 151 | + await page.locator('[data-open-filters]').click(); | |
| 152 | + const sheetForm = await page.waitForSelector('[role="dialog"] [data-price-filters]', { timeout: 8000 }).catch(() => null); | |
| 153 | + step(!!sheetForm, 'prices mobile: filter sheet shows the rail form'); | |
| 154 | + await page.screenshot({ path: `${OUT}flow-prices-filter-sheet-390.png` }); | |
| 155 | + await page.keyboard.press('Escape'); | |
| 156 | + await page.setViewportSize({ width: 1440, height: 900 }); | |
| 157 | + | |
| 158 | + // calculator: URL-seeded model → cost rows; switch to Context tab → context rows; URL mirrors the tab | |
| 159 | + await page.goto(`${BASE}/calculator?model=${model}&input_tokens=2000&output_tokens=800`, { waitUntil: 'networkidle' }); | |
| 160 | + const costRows = await page.waitForSelector('[data-cost-row]', { timeout: 20000 }).catch(() => null); | |
| 161 | + step(!!costRows, 'calculator: cost rows render for the URL model'); | |
| 162 | + await page.screenshot({ path: `${OUT}flow-calculator-workload.png` }); | |
| 163 | + await page.locator('[data-calc-tab="context"]').click(); | |
| 164 | + const ctxRows = await page.waitForSelector('[data-context-row]', { timeout: 20000 }).catch(() => null); | |
| 165 | + await page.waitForURL(/tab=context/, { timeout: 10000 }).catch(() => undefined); | |
| 166 | + step(!!ctxRows && /tab=context/.test(page.url()), `calculator: context tab renders rows and URL carries tab=context (${page.url().split('?')[1]})`); | |
| 167 | + await page.screenshot({ path: `${OUT}flow-calculator-context.png` }); | |
| 168 | + | |
| 169 | + // provider page: expand a deployment → step chart or honest note | |
| 170 | + await page.goto(`${BASE}/providers/${topProvider}`, { waitUntil: 'networkidle' }); | |
| 171 | + await page.locator('[data-expand-history]').first().click(); | |
| 172 | + const ph = await page.waitForSelector('[data-offer-history]', { timeout: 15000 }).catch(() => null); | |
| 173 | + await page.waitForTimeout(1500); | |
| 174 | + step(!!ph, 'provider: deployment row expands to price history'); | |
| 175 | + await page.screenshot({ path: `${OUT}flow-provider-history.png` }); | |
| 176 | + | |
| 177 | + // run-locally: form submit round-trips through the URL and shows the Estimated banner | |
| 178 | + await page.goto(`${BASE}/run-locally`, { waitUntil: 'networkidle' }); | |
| 179 | + await page.selectOption('[data-run-locally-form] select[name="memory_gb"]', '128'); | |
| 180 | + await page.selectOption('[data-run-locally-form] select[name="quant"]', '8bit'); | |
| 181 | + await page.locator('[data-run-locally-form] button[type="submit"]').click(); | |
| 182 | + await page.waitForLoadState('networkidle'); | |
| 183 | + const banner = await page.$('[data-estimate-banner]'); | |
| 184 | + step(!!banner && /memory_gb=128/.test(page.url()) && /quant=8bit/.test(page.url()), `run-locally: submit → ${page.url().split('?')[1]} with estimate banner`); | |
| 185 | + await page.screenshot({ path: `${OUT}flow-run-locally-128-8bit.png` }); | |
| 186 | + | |
| 187 | + // frontier: section nav present and the efficiency scatter renders points | |
| 188 | + await page.goto(`${BASE}/frontier`, { waitUntil: 'networkidle' }); | |
| 189 | + const nav = await page.$('[data-section-nav]'); | |
| 190 | + const scatter = await page.$('[data-scatter] circle'); | |
| 191 | + step(!!nav && !!scatter, 'frontier: section nav + efficiency scatter points'); | |
| 192 | + | |
| 193 | + const filtered = filterErrors(errors, 200); | |
| 194 | + step(filtered.length === 0, `flows: zero console errors${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`); | |
| 195 | + await ctx.close(); | |
| 196 | +} | |
| 197 | + | |
| 198 | +// ---------------------------------------------------------------------------------------------------------------- OG images | |
| 199 | +for (const p of OG) { | |
| 200 | + const r = await fetch(BASE + p).catch(() => null); | |
| 201 | + const ok = !!r && r.status === 200 && /image\/png/.test(r.headers.get('content-type') ?? ''); | |
| 202 | + if (!ok) failures++; | |
| 203 | + console.log(`${ok ? 'OK ' : 'FAIL'} og ${r?.status ?? 'ERR'} ${r?.headers.get('content-type') ?? ''} ${p}`); | |
| 204 | +} | |
| 205 | + | |
| 206 | +await browser.close(); | |
| 207 | +console.log(`\nsmall touch targets seen on mobile widths (informational): ${smallTotal}`); | |
| 208 | +console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK'); | |
| 209 | +process.exit(failures ? 1 : 0); | |
added
apps/web/src/app/agents/page.tsx
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Dash, IntelListing, list, str } from '@/components/intelligence/listing-table'; | |
| 4 | +import { Chip, EntityBadge } from '@/components/ui/badges'; | |
| 5 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 6 | +import { api, safe } from '@/lib/api'; | |
| 7 | +import { fmtDate } from '@/lib/format'; | |
| 8 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 9 | +import type { EntitySummary } from '@/lib/types'; | |
| 10 | + | |
| 11 | +export const metadata: Metadata = { title: 'AI agents — developer, kind, underlying models, platform, licence', description: 'Agents recorded in the atlas: developer, agent kind, the models they run on (relations), platform, repository, licence and release date — from official sources only.', alternates: { canonical: routes.agents() }, openGraph: { title: `AI agents | ${SITE_NAME}`, url: `${SITE_URL}${routes.agents()}`, siteName: SITE_NAME } }; | |
| 12 | +export const revalidate = 300; | |
| 13 | + | |
| 14 | +type Extra = { models: EntitySummary[]; total: number }; | |
| 15 | +const AGENT_KINDS: Record<string, string> = { coding: 'Coding agent', browser: 'Browser agent', computer_use: 'Computer use', research: 'Research agent', assistant: 'Assistant', workflow: 'Workflow', voice: 'Voice agent', mcp_server: 'MCP server' }; | |
| 16 | + | |
| 17 | +export default async function AgentsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 18 | + const sp = await searchParams; | |
| 19 | + return ( | |
| 20 | + <IntelListing<Extra> | |
| 21 | + title="Agents" | |
| 22 | + eyebrow="Agents & tools" | |
| 23 | + lede={<>Autonomous and semi-autonomous systems built on the models in the atlas: who develops them, what kind they are, which models they run on, where they run. Related software lives under <Link href={routes.frameworks()} className="link">Frameworks</Link>; developer tools under <Link href={routes.tools()} className="link">Tools</Link>.</>} | |
| 24 | + basePath="/agents" | |
| 25 | + searchParams={sp} | |
| 26 | + fetch={(q) => api.explore('agent', q)} | |
| 27 | + sorts={[ | |
| 28 | + { value: 'updated', label: 'Recently updated' }, | |
| 29 | + { value: 'release', label: 'Release date' }, | |
| 30 | + { value: 'stars', label: 'Stars' }, | |
| 31 | + { value: 'name', label: 'Name' }, | |
| 32 | + ]} | |
| 33 | + filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []} | |
| 34 | + connectors={['github (agent repositories)', 'official product pages', 'mcp registries']} | |
| 35 | + emptyHint={<>The atlas declares the <span className="mono">agent</span> entity type but no connector has produced one yet. <Link href={routes.frameworks()} className="link">Agent frameworks</Link> (LangChain, CrewAI, …) are listed under Frameworks.</>} | |
| 36 | + enrich={async (items) => { | |
| 37 | + const details = await Promise.all(items.map((e) => safe(api.entity(e.slug)))); | |
| 38 | + const m = new Map<string, Extra>(); | |
| 39 | + details.forEach((d, i) => { | |
| 40 | + if (!d) return; | |
| 41 | + const rel = (d.relations ?? []).filter((g) => g.direction === 'out' && ['uses', 'runs_on', 'derived_from', 'integrates'].includes(g.predicate)); | |
| 42 | + const models = rel.flatMap((g) => g.items.filter((x) => x.entity_type === 'model')); | |
| 43 | + m.set(items[i]!.slug, { models, total: rel.reduce((n, g) => n + g.total, 0) }); | |
| 44 | + }); | |
| 45 | + return m; | |
| 46 | + }} | |
| 47 | + columns={[ | |
| 48 | + { | |
| 49 | + key: 'name', | |
| 50 | + label: 'Agent', | |
| 51 | + primary: true, | |
| 52 | + render: (e) => ( | |
| 53 | + <> | |
| 54 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 55 | + <EntityLink e={e} /> | |
| 56 | + {e.entity_type !== 'agent' && <EntityBadge type={e.entity_type} small />} | |
| 57 | + </span> | |
| 58 | + {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>} | |
| 59 | + </> | |
| 60 | + ), | |
| 61 | + }, | |
| 62 | + { key: 'developer', label: 'Developer', className: 'text-ink-2', render: (e) => (e.organization ? <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">{e.organization.name}</Link> : str(e.attributes?.developer) ?? <Dash />) }, | |
| 63 | + { key: 'kind', label: 'Agent kind', render: (e) => { const k = str(e.attributes?.agent_kind) ?? str(e.attributes?.kind) ?? str(e.attributes?.category); return k ? <Chip>{AGENT_KINDS[k] ?? k.replace(/_/g, ' ')}</Chip> : <Dash />; } }, | |
| 64 | + { | |
| 65 | + key: 'models', | |
| 66 | + label: 'Underlying models', | |
| 67 | + render: (e, x) => { | |
| 68 | + const fromAttr = list(e.attributes?.models ?? e.attributes?.underlying_models); | |
| 69 | + if (x?.models.length) return <span className="flex flex-wrap gap-1">{x.models.slice(0, 4).map((m) => <EntityLink key={m.id} e={m} className="text-xs" />)}{x.models.length > 4 && <span className="text-xs text-ink-3">+{x.models.length - 4}</span>}</span>; | |
| 70 | + return fromAttr.length ? <span className="text-xs text-ink-2">{fromAttr.join(', ')}</span> : <Dash />; | |
| 71 | + }, | |
| 72 | + }, | |
| 73 | + { key: 'platform', label: 'Platform', className: 'text-ink-2 text-xs', render: (e) => { const p = list(e.attributes?.platforms ?? e.attributes?.platform); return p.length ? p.join(' · ') : <Dash />; } }, | |
| 74 | + { key: 'repo', label: 'Repository', className: 'text-xs', render: (e) => { const u = str(e.attributes?.repository_url) ?? str(e.attributes?.official_url); return u ? <a href={u} target="_blank" rel="noopener noreferrer" className="text-ink-2 hover:text-accent">{u.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 40)}</a> : <Dash />; } }, | |
| 75 | + { key: 'license', label: 'Licence', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> }, | |
| 76 | + { key: 'release', label: 'Release', className: 'tnum text-ink-2 whitespace-nowrap', render: (e) => (str(e.attributes?.release_date) ? fmtDate(str(e.attributes.release_date)) : <Dash />) }, | |
| 77 | + { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> }, | |
| 78 | + ]} | |
| 79 | + note="Underlying models come from graph relations (uses · runs_on · derived_from · integrates) on each agent, else from the agent's stated attributes; a dash means no source has stated it." | |
| 80 | + /> | |
| 81 | + ); | |
| 82 | +} | |
added
apps/web/src/app/calculator/page.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Suspense } from 'react'; | |
| 4 | +import { Calculator } from '@/components/intelligence/calculator'; | |
| 5 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 6 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 7 | + | |
| 8 | +type SP = Record<string, string | undefined>; | |
| 9 | +const TITLE = 'AI cost calculator — price a workload across every provider'; | |
| 10 | +const DESC = 'Pick a model, enter tokens per request, requests per day, cached share and batch: the calculator prices the workload per request, day, month and year for every provider that currently serves the model, from published prices only, plus the cost of filling a 128K, 200K, 1M or 2M-token context.'; | |
| 11 | + | |
| 12 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 13 | + const sp = await searchParams; | |
| 14 | + const title = sp.model ? `Cost of ${sp.name ?? sp.model} per request, day, month and year — every provider` : TITLE; | |
| 15 | + return { title, description: DESC, alternates: { canonical: routes.calculator() }, openGraph: { title: `${title} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.calculator()}`, siteName: SITE_NAME }, robots: Object.keys(sp).length ? { index: false, follow: true } : undefined }; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default function CalculatorPage() { | |
| 19 | + return ( | |
| 20 | + <Container wide> | |
| 21 | + <PageHeader eyebrow="Cost calculator" title="What will this workload cost?" lede="Per request, per day, per month, per year — for every provider that currently serves the model, side by side, from the prices they publish. The second tab prices a fully populated context window. Inputs live in the URL, so a result is shareable." /> | |
| 22 | + <Suspense fallback={<p className="py-10 text-center text-sm text-ink-3" aria-busy="true">Loading calculator…</p>}> | |
| 23 | + <Calculator /> | |
| 24 | + </Suspense> | |
| 25 | + <Note className="mt-8 pb-16"> | |
| 26 | + Computation: <Link href="/developers" className="link">GET /cost</Link> and <Link href="/developers" className="link">GET /cost/context</Link> on current offers only — no rate limits, latency or quality are modelled. See also the <Link href={routes.prices()} className="link">price terminal</Link> and <Link href={routes.findAModel()} className="link">Find a model</Link>. | |
| 27 | + </Note> | |
| 28 | + </Container> | |
| 29 | + ); | |
| 30 | +} | |
modified
apps/web/src/app/datasets/page.tsx
+30 −33
@@ -1,22 +1,24 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { Dash, list, str, TypedListing } from '@/components/listing/typed-listing'; | |
| 3 | +import { Dash, IntelListing, list, str } from '@/components/intelligence/listing-table'; | |
| 4 | 4 | import { Chip } from '@/components/ui/badges'; |
| 5 | 5 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
| 6 | −import { api } from '@/lib/api'; | |
| 7 | −import { fmtAgo, fmtValue } from '@/lib/format'; | |
| 8 | −import { routes } from '@/lib/site'; | |
| 6 | +import { api, safe } from '@/lib/api'; | |
| 7 | +import { fmtInt, fmtValue } from '@/lib/format'; | |
| 8 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 9 | 9 | |
| 10 | −export const metadata: Metadata = { title: 'AI datasets — modality, size, license', description: 'Training and evaluation datasets in the atlas with modality, size, license and publisher as stated by their hosts.', alternates: { canonical: '/datasets' } }; | |
| 10 | +export const metadata: Metadata = { title: 'AI datasets — publisher, modality, size, licence, access, models trained on them', description: 'Training and evaluation datasets in the atlas with publisher, modality, size, licence and access as stated by their hosts, plus how many models the graph records as trained on each and the papers that describe them.', alternates: { canonical: routes.datasets() }, openGraph: { title: `AI datasets | ${SITE_NAME}`, url: `${SITE_URL}${routes.datasets()}`, siteName: SITE_NAME } }; | |
| 11 | 11 | export const revalidate = 300; |
| 12 | 12 | |
| 13 | +type Extra = { trained: number; papers: number; access: string | null }; | |
| 14 | + | |
| 13 | 15 | export default async function DatasetsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 14 | 16 | const sp = await searchParams; |
| 15 | 17 | return ( |
| 16 | − <TypedListing | |
| 18 | + <IntelListing<Extra> | |
| 17 | 19 | title="Datasets" |
| 18 | 20 | eyebrow="Datasets" |
| 19 | − lede="Training and evaluation datasets referenced by models and benchmarks. Modality, size and license are read from dataset cards and hosting pages." | |
| 21 | + lede="Training and evaluation datasets referenced by models and benchmarks. Modality, size, licence and access are read from dataset cards and hosting pages; the “used to train” count comes from the graph (uses_dataset relations)." | |
| 20 | 22 | basePath="/datasets" |
| 21 | 23 | searchParams={sp} |
| 22 | 24 | fetch={(q) => api.explore('dataset', q)} |
@@ -27,7 +29,20 @@ export default async function DatasetsPage({ searchParams }: { searchParams: Pro | ||
| 27 | 29 | { value: 'quality', label: 'Data quality' }, |
| 28 | 30 | ]} |
| 29 | 31 | filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []} |
| 30 | − emptyTitle="No datasets match" | |
| 32 | + connectors={['huggingface (dataset cards)', 'arxiv (papers naming datasets)', 'model cards']} | |
| 33 | + enrich={async (items) => { | |
| 34 | + const details = await Promise.all(items.map((e) => safe(api.entity(e.slug)))); | |
| 35 | + const m = new Map<string, Extra>(); | |
| 36 | + details.forEach((d, i) => { | |
| 37 | + if (!d) return; | |
| 38 | + const trained = (d.relations ?? []).filter((g) => g.predicate === 'uses_dataset' && g.direction === 'in').reduce((n, g) => n + (g.total || g.items.length), 0); | |
| 39 | + const papers = (d.papers?.length ?? 0) + (d.relations ?? []).filter((g) => g.predicate === 'described_by' && g.direction === 'out').reduce((n, g) => n + (g.total || g.items.length), 0); | |
| 40 | + const a = d.attributes ?? {}; | |
| 41 | + const access = typeof a.access === 'string' ? a.access : a.gated === true ? 'gated' : a.gated === false ? 'public' : null; | |
| 42 | + m.set(items[i]!.slug, { trained, papers, access }); | |
| 43 | + }); | |
| 44 | + return m; | |
| 45 | + }} | |
| 31 | 46 | columns={[ |
| 32 | 47 | { |
| 33 | 48 | key: 'name', |
@@ -40,41 +55,23 @@ export default async function DatasetsPage({ searchParams }: { searchParams: Pro | ||
| 40 | 55 | </> |
| 41 | 56 | ), |
| 42 | 57 | }, |
| 58 | + { key: 'publisher', label: 'Publisher', className: 'text-ink-2', render: (e) => (e.organization ? <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">{e.organization.name}</Link> : str(e.attributes?.publisher) ?? <Dash />) }, | |
| 43 | 59 | { |
| 44 | 60 | key: 'modality', |
| 45 | 61 | label: 'Modality', |
| 46 | 62 | render: (e) => { |
| 47 | 63 | const m = str(e.attributes?.modality) ? [str(e.attributes.modality) as string] : list(e.attributes?.modalities); |
| 48 | − return m.length ? ( | |
| 49 | − <span className="flex flex-wrap gap-1"> | |
| 50 | − {m.slice(0, 3).map((x) => ( | |
| 51 | − <Chip key={x}>{x}</Chip> | |
| 52 | − ))} | |
| 53 | − </span> | |
| 54 | − ) : ( | |
| 55 | − <Dash /> | |
| 56 | − ); | |
| 64 | + return m.length ? <span className="flex flex-wrap gap-1">{m.slice(0, 3).map((x) => <Chip key={x}>{x}</Chip>)}</span> : <Dash />; | |
| 57 | 65 | }, |
| 58 | 66 | }, |
| 59 | 67 | { key: 'size', label: 'Size', className: 'tnum text-ink-2', render: (e) => (e.attributes?.size ? fmtValue(e.attributes.size, 'size') : <Dash />) }, |
| 60 | − { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> }, | |
| 61 | − { | |
| 62 | − key: 'publisher', | |
| 63 | − label: 'Publisher', | |
| 64 | − className: 'text-ink-2', | |
| 65 | − render: (e) => | |
| 66 | − e.organization ? ( | |
| 67 | − <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent"> | |
| 68 | − {e.organization.name} | |
| 69 | − </Link> | |
| 70 | − ) : ( | |
| 71 | − str(e.attributes?.publisher) ?? <Dash /> | |
| 72 | − ), | |
| 73 | − }, | |
| 74 | − { key: 'updated', label: 'Updated', className: 'text-ink-2 whitespace-nowrap', render: (e) => <span title={e.updated_at}>{fmtAgo(e.updated_at)}</span> }, | |
| 68 | + { key: 'license', label: 'Licence', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> }, | |
| 69 | + { key: 'access', label: 'Access', className: 'text-ink-2', render: (e, x) => (x?.access ? <Chip tone={x.access === 'gated' ? 'accent' : 'neutral'}>{x.access}</Chip> : str(e.attributes?.access) ?? <Dash />) }, | |
| 70 | + { key: 'trained', label: 'Used to train', num: true, className: 'tnum', render: (_e, x) => (x ? (x.trained ? fmtInt(x.trained) : <span className="text-ink-3">0</span>) : <Dash />) }, | |
| 71 | + { key: 'papers', label: 'Papers', num: true, className: 'tnum', render: (_e, x) => (x ? (x.papers ? fmtInt(x.papers) : <span className="text-ink-3">0</span>) : <Dash />) }, | |
| 75 | 72 | { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> }, |
| 76 | 73 | ]} |
| 77 | − note="Many datasets are known only by reference (a model card names them) and carry no attributes yet — the dash means the sources have not stated it." | |
| 74 | + note="Many datasets are known only by reference (a model card names them) and carry no attributes yet — the dash means the sources have not stated it. “Used to train” counts inbound uses_dataset relations; “Papers” counts papers linked to the dataset." | |
| 78 | 75 | /> |
| 79 | 76 | ); |
| 80 | 77 | } |
added
apps/web/src/app/find-a-model/page.tsx
+227 −0
@@ -0,0 +1,227 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 4 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 5 | +import { QUANTS } from '@/components/hardware/fit-form'; | |
| 6 | +import { FINDER_USE_CASES, FinderForm, type FinderInputs, MODALITIES } from '@/components/intelligence/finder-form'; | |
| 7 | +import { FitCell, Methodology, RankChips } from '@/components/intelligence/bits'; | |
| 8 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 9 | +import { Chip, Estimated, OpennessBadge } from '@/components/ui/badges'; | |
| 10 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 11 | +import { EntityLink } from '@/components/ui/entity'; | |
| 12 | +import { Note, PageHeader } from '@/components/ui/section'; | |
| 13 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 14 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 15 | +import { ApiError, intel, safe } from '@/lib/api'; | |
| 16 | +import { fmtDate, fmtInt, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format'; | |
| 17 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 18 | +import type { FinderPayload } from '@/lib/types'; | |
| 19 | + | |
| 20 | +export const revalidate = 300; | |
| 21 | +type SP = Record<string, string | string[] | undefined>; | |
| 22 | +const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); | |
| 23 | + | |
| 24 | +function parse(sp: SP): FinderInputs { | |
| 25 | + const custom = num(one(sp.memory_custom)); | |
| 26 | + const preset = num(one(sp.memory_gb)); | |
| 27 | + const mods = (Array.isArray(sp.mod) ? sp.mod : sp.mod ? [sp.mod] : one(sp.modalities)?.split(',') ?? []).filter((m) => MODALITIES.includes(m)); | |
| 28 | + const dep = one(sp.deployment); | |
| 29 | + const lic = one(sp.license); | |
| 30 | + return { | |
| 31 | + useCase: FINDER_USE_CASES.some((u) => u.value === one(sp.use_case)) ? (one(sp.use_case) as string) : '', | |
| 32 | + deployment: dep === 'local' || dep === 'api' ? dep : 'any', | |
| 33 | + memory: custom !== null && custom > 0 ? custom : preset !== null && preset > 0 ? preset : null, | |
| 34 | + quant: QUANTS.some((q) => q.value === one(sp.quant)) ? (one(sp.quant) as string) : '4bit', | |
| 35 | + contextMin: num(one(sp.context_min)), | |
| 36 | + license: lic === 'commercial' ? 'commercial' : 'any', | |
| 37 | + openness: ['open-source', 'open-weights', 'restricted-weights', 'proprietary'].includes(one(sp.openness) ?? '') ? (one(sp.openness) as string) : '', | |
| 38 | + maxIn: num(one(sp.max_input_price)), | |
| 39 | + maxOut: num(one(sp.max_output_price)), | |
| 40 | + modalities: mods, | |
| 41 | + }; | |
| 42 | +} | |
| 43 | +const active = (v: FinderInputs) => [v.useCase, v.deployment !== 'any', v.memory, v.contextMin, v.license !== 'any', v.openness, v.maxIn, v.maxOut, v.modalities.length].filter(Boolean).length; | |
| 44 | + | |
| 45 | +const TITLE = 'Find a model — guided search over observed dimensions'; | |
| 46 | +const DESC = 'Tell the atlas what you need — use case, local or API deployment, memory, licence, openness, context, price ceiling, modalities — and it lists the canonical models that satisfy the criteria, with the reasons, their observed dimensions, an estimated local fit and current deployments. Deterministic rules, no composite score, no single winner.'; | |
| 47 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 48 | + const v = parse(await searchParams); | |
| 49 | + const n = active(v); | |
| 50 | + const title = n ? `Models for ${[FINDER_USE_CASES.find((u) => u.value === v.useCase)?.label, v.deployment !== 'any' ? v.deployment : null, v.memory ? `${v.memory} GB` : null].filter(Boolean).join(' · ') || 'your criteria'}` : TITLE; | |
| 51 | + return { title, description: DESC, alternates: { canonical: routes.findAModel() }, openGraph: { title: `${title} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.findAModel()}`, siteName: SITE_NAME }, robots: n ? { index: false, follow: true } : undefined }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export default async function FindAModelPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 55 | + const sp = await searchParams; | |
| 56 | + const v = parse(sp); | |
| 57 | + const n = active(v); | |
| 58 | + let res: FinderPayload | null = null; | |
| 59 | + let error: string | null = null; | |
| 60 | + if (n > 0) { | |
| 61 | + try { | |
| 62 | + res = await intel.findAModel({ use_case: v.useCase || undefined, deployment: v.deployment, memory_gb: v.memory ?? undefined, quant: v.deployment === 'local' || v.memory ? v.quant : undefined, context_min: v.contextMin ?? undefined, license: v.license, openness: v.openness || undefined, max_input_price: v.maxIn ?? undefined, max_output_price: v.maxOut ?? undefined, modalities: v.modalities.length ? v.modalities.join(',') : undefined, limit: 40 }); | |
| 63 | + } catch (e) { | |
| 64 | + error = e instanceof ApiError ? e.detail ?? e.message : 'API unreachable'; | |
| 65 | + } | |
| 66 | + } | |
| 67 | + const rules = res?.rules ?? (await safe(intel.methodology()))?.find_a_model ?? null; | |
| 68 | + const rulesObj = rules && typeof rules === 'object' ? (rules as Record<string, string>) : null; | |
| 69 | + const apiMode = v.deployment === 'api'; | |
| 70 | + const localMode = v.deployment === 'local' || v.memory !== null; | |
| 71 | + | |
| 72 | + const inspector = ( | |
| 73 | + <div className="space-y-3 text-xs leading-relaxed text-ink-2"> | |
| 74 | + <p className="font-medium text-ink">Rules (deterministic)</p> | |
| 75 | + {rulesObj ? ( | |
| 76 | + <dl className="space-y-1.5"> | |
| 77 | + {Object.entries(rulesObj).map(([k, r]) => ( | |
| 78 | + <div key={k}> | |
| 79 | + <dt className="mono text-[11px] text-ink-3">{k}</dt> | |
| 80 | + <dd>{r}</dd> | |
| 81 | + </div> | |
| 82 | + ))} | |
| 83 | + </dl> | |
| 84 | + ) : ( | |
| 85 | + <p className="text-ink-3">Rules unavailable from the API.</p> | |
| 86 | + )} | |
| 87 | + <p> | |
| 88 | + <Link href="/methodology" className="link">/methodology</Link> · <Link href="/developers" className="link">GET /find-a-model</Link> | |
| 89 | + </p> | |
| 90 | + </div> | |
| 91 | + ); | |
| 92 | + | |
| 93 | + return ( | |
| 94 | + <TerminalLayout filters={<FinderForm v={v} />} inspector={inspector} filtersTitle="What do you need?" inspectorTitle="Rules" storageKey="aia-inspector-finder" filterCount={n}> | |
| 95 | + <PageHeader eyebrow="Model finder" title="Find a model" lede="Describe the need; the atlas applies deterministic rules to observed attributes, current prices and current benchmark results and returns the models that satisfy them — sorted by how many criteria they meet, then best benchmark rank, then release date. No composite score, no single winner." className="pt-4 md:pt-6" /> | |
| 96 | + <div className="pb-16"> | |
| 97 | + {n === 0 ? ( | |
| 98 | + <EmptyState title="Pick at least one criterion"> | |
| 99 | + Examples: <Link href="/find-a-model?use_case=coding&deployment=local&memory_gb=64" className="link">coding · local · 64 GB</Link>, <Link href="/find-a-model?use_case=vision&deployment=api&max_output_price=5" className="link">vision · API · output ≤ $5</Link>, <Link href="/find-a-model?use_case=long_context&context_min=1000000&license=commercial" className="link">1M context · commercial licence</Link>. | |
| 100 | + </EmptyState> | |
| 101 | + ) : !res ? ( | |
| 102 | + <Unavailable what="Model finder" reason={error ?? undefined} /> | |
| 103 | + ) : ( | |
| 104 | + <> | |
| 105 | + <div className="border-y border-rule py-3"> | |
| 106 | + <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm"> | |
| 107 | + <span className="font-semibold text-ink"> | |
| 108 | + {fmtInt(res.total)} model{res.total === 1 ? '' : 's'} satisfy {res.matches[0]?.why.length ? 'at least one of' : ''} the criteria | |
| 109 | + </span> | |
| 110 | + <span className="text-ink-3">No single winner — these are the observed dimensions; the order is criteria met → best rank → release date.</span> | |
| 111 | + </p> | |
| 112 | + <p className="mt-1.5 flex flex-wrap gap-1.5"> | |
| 113 | + {Object.entries(res.filters_applied).map(([k, val]) => ( | |
| 114 | + <Chip key={k}> | |
| 115 | + <span className="text-ink-3">{k.replace(/_/g, ' ')}</span> {String(val)} | |
| 116 | + </Chip> | |
| 117 | + ))} | |
| 118 | + </p> | |
| 119 | + </div> | |
| 120 | + {res.matches.length === 0 ? ( | |
| 121 | + <EmptyState title="No model satisfies these criteria" className="mt-4"> | |
| 122 | + Relax a bound (price, memory, context) or drop a modality. Every rule is listed in the Rules pane. | |
| 123 | + </EmptyState> | |
| 124 | + ) : ( | |
| 125 | + <DataTable scroll compact className="mt-4"> | |
| 126 | + <thead> | |
| 127 | + <tr> | |
| 128 | + <Th>Model</Th> | |
| 129 | + <Th>Why</Th> | |
| 130 | + <Th>Observed dimensions</Th> | |
| 131 | + {localMode && ( | |
| 132 | + <Th> | |
| 133 | + Fit <Estimated className="ml-1 align-middle" /> | |
| 134 | + </Th> | |
| 135 | + )} | |
| 136 | + {apiMode && <Th>Deployments</Th>} | |
| 137 | + <Th>Ranks</Th> | |
| 138 | + <Th className="w-40" aria-label="Actions" /> | |
| 139 | + </tr> | |
| 140 | + </thead> | |
| 141 | + <tbody> | |
| 142 | + {res.matches.length === 0 && <EmptyRow cols={7} />} | |
| 143 | + {res.matches.map((m) => { | |
| 144 | + const o = m.observed ?? {}; | |
| 145 | + const mods = Array.isArray(o.modalities) ? (o.modalities as string[]) : []; | |
| 146 | + return ( | |
| 147 | + <tr key={m.model.id} data-finder-row> | |
| 148 | + <Td primary> | |
| 149 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 150 | + <EntityLink e={m.model} /> | |
| 151 | + {typeof o.openness === 'string' && <OpennessBadge openness={o.openness} />} | |
| 152 | + </span> | |
| 153 | + {m.model.organization && <span className="block text-xs text-ink-3">{m.model.organization.name}</span>} | |
| 154 | + </Td> | |
| 155 | + <Td label="Why" wide> | |
| 156 | + <ul className="flex flex-wrap gap-1"> | |
| 157 | + {m.why.map((w) => ( | |
| 158 | + <li key={w} className={`rounded-[3px] px-1.5 py-[1px] text-[11px] leading-4 ${/estimate/i.test(w) ? 'border border-dashed border-warning/60 text-warning' : 'bg-positive-soft text-positive'}`}> | |
| 159 | + {w} | |
| 160 | + </li> | |
| 161 | + ))} | |
| 162 | + {m.why.length === 0 && <li className="text-xs text-ink-3">—</li>} | |
| 163 | + </ul> | |
| 164 | + </Td> | |
| 165 | + <Td label="Observed" className="tnum text-xs text-ink-2" wide> | |
| 166 | + {[ | |
| 167 | + num(o.parameter_count) !== null ? `${fmtParams(o.parameter_count)} params` : null, | |
| 168 | + num(o.context_length) !== null ? `${fmtTokens(o.context_length)} context` : null, | |
| 169 | + typeof o.license === 'string' ? o.license : null, | |
| 170 | + mods.length ? mods.join(' · ') : null, | |
| 171 | + o.reasoning === true ? 'reasoning' : null, | |
| 172 | + o.tool_calling === true ? 'tool calling' : null, | |
| 173 | + typeof o.release_date === 'string' ? `released ${fmtDate(o.release_date)}` : null, | |
| 174 | + num(o.cheapest_input_per_mtok) !== null ? `from ${fmtUsdPerM(o.cheapest_input_per_mtok)} in / ${fmtUsdPerM(o.cheapest_output_per_mtok)} out` : null, | |
| 175 | + num(o.providers) !== null ? `${fmtInt(o.providers)} provider${num(o.providers) === 1 ? '' : 's'}` : null, | |
| 176 | + ] | |
| 177 | + .filter(Boolean) | |
| 178 | + .join(' · ') || '—'} | |
| 179 | + </Td> | |
| 180 | + {localMode && ( | |
| 181 | + <Td label="Fit (estimated)"> | |
| 182 | + <FitCell fit={m.estimated_fit} /> | |
| 183 | + </Td> | |
| 184 | + )} | |
| 185 | + {apiMode && ( | |
| 186 | + <Td label="Deployments" className="text-xs"> | |
| 187 | + {m.deployments && m.deployments.length ? ( | |
| 188 | + <ul className="space-y-0.5"> | |
| 189 | + {m.deployments.slice(0, 4).map((d) => ( | |
| 190 | + <li key={d.id} className="tnum flex flex-wrap gap-x-2"> | |
| 191 | + <EntityLink e={d.provider} className="text-ink-2" /> | |
| 192 | + <span className="text-accent-2"> | |
| 193 | + {fmtUsdPerM(d.prices.input)} / {fmtUsdPerM(d.prices.output)} | |
| 194 | + </span> | |
| 195 | + </li> | |
| 196 | + ))} | |
| 197 | + {m.deployments.length > 4 && <li className="text-ink-3">+{m.deployments.length - 4} more</li>} | |
| 198 | + </ul> | |
| 199 | + ) : ( | |
| 200 | + <span className="text-ink-3">—</span> | |
| 201 | + )} | |
| 202 | + </Td> | |
| 203 | + )} | |
| 204 | + <Td label="Ranks"> | |
| 205 | + <RankChips ranks={o.benchmark_ranks ?? null} max={3} /> | |
| 206 | + </Td> | |
| 207 | + <Td className="text-right"> | |
| 208 | + <span className="inline-flex flex-wrap justify-end gap-1"> | |
| 209 | + <CompareButton e={m.model} size="sm" /> | |
| 210 | + <WatchButton e={m.model} size="sm" /> | |
| 211 | + </span> | |
| 212 | + </Td> | |
| 213 | + </tr> | |
| 214 | + ); | |
| 215 | + })} | |
| 216 | + </tbody> | |
| 217 | + </DataTable> | |
| 218 | + )} | |
| 219 | + <Note className="mt-3">Green chips = criteria satisfied by an observed fact; dashed amber = satisfied by an estimate (hardware fit). Ranks are positions inside each benchmark's primary comparability group.</Note> | |
| 220 | + <Methodology text={res.note} /> | |
| 221 | + </> | |
| 222 | + )} | |
| 223 | + </div> | |
| 224 | + <CompareTrayBar /> | |
| 225 | + </TerminalLayout> | |
| 226 | + ); | |
| 227 | +} | |
modified
apps/web/src/app/frameworks/page.tsx
+63 −28
@@ -1,22 +1,46 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { Dash, str, TypedListing } from '@/components/listing/typed-listing'; | |
| 4 | −import { EntityBadge } from '@/components/ui/badges'; | |
| 3 | +import { Sparkline } from '@/components/charts'; | |
| 4 | +import { Dash, IntelListing, str } from '@/components/intelligence/listing-table'; | |
| 5 | +import { Chip } from '@/components/ui/badges'; | |
| 5 | 6 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
| 6 | −import { api } from '@/lib/api'; | |
| 7 | +import { api, safe } from '@/lib/api'; | |
| 7 | 8 | import { fmtDate, fmtInt, num } from '@/lib/format'; |
| 8 | −import { routes } from '@/lib/site'; | |
| 9 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 9 | 10 | |
| 10 | −export const metadata: Metadata = { title: 'AI frameworks, libraries and runtimes — versions, licenses, stars', description: 'Training, inference and agent frameworks with latest version, release date, license and repository stars read from PyPI, GitHub and release pages.', alternates: { canonical: '/frameworks' } }; | |
| 11 | +export const metadata: Metadata = { title: 'AI frameworks, inference engines, libraries and runtimes — versions, licences, stars', description: 'Training frameworks, inference engines, serving stacks, libraries, runtimes, agent frameworks, orchestration and eval harnesses with latest version, release date, licence and repository stars read from PyPI, GitHub and release pages.', alternates: { canonical: routes.frameworks() }, openGraph: { title: `AI frameworks | ${SITE_NAME}`, url: `${SITE_URL}${routes.frameworks()}`, siteName: SITE_NAME } }; | |
| 11 | 12 | export const revalidate = 300; |
| 12 | 13 | |
| 14 | +type Extra = { stars: number[] }; | |
| 15 | +/** Canonical kind vocabulary → label; entity_type is the fallback when no `kind` attribute is stated. */ | |
| 16 | +const KIND_LABELS: Record<string, string> = { | |
| 17 | + training_framework: 'Training framework', | |
| 18 | + training: 'Training framework', | |
| 19 | + inference_engine: 'Inference engine', | |
| 20 | + inference: 'Inference engine', | |
| 21 | + serving: 'Serving', | |
| 22 | + library: 'Library', | |
| 23 | + runtime: 'Runtime', | |
| 24 | + agent_framework: 'Agent framework', | |
| 25 | + agent: 'Agent framework', | |
| 26 | + orchestration: 'Orchestration', | |
| 27 | + eval_harness: 'Eval harness', | |
| 28 | + evaluation: 'Eval harness', | |
| 29 | + framework: 'Framework', | |
| 30 | +}; | |
| 31 | +const kindOf = (e: { entity_type: string; attributes: Record<string, unknown> }) => { | |
| 32 | + const k = str(e.attributes?.kind) ?? str(e.attributes?.category); | |
| 33 | + if (k) return KIND_LABELS[k.toLowerCase().replace(/[\s-]+/g, '_')] ?? k; | |
| 34 | + return KIND_LABELS[e.entity_type] ?? e.entity_type; | |
| 35 | +}; | |
| 36 | + | |
| 13 | 37 | export default async function FrameworksPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 14 | 38 | const sp = await searchParams; |
| 15 | 39 | return ( |
| 16 | − <TypedListing | |
| 40 | + <IntelListing<Extra> | |
| 17 | 41 | title="Frameworks & runtimes" |
| 18 | 42 | eyebrow="Frameworks" |
| 19 | − lede="Libraries, inference engines and agent frameworks. Versions and stars are read from PyPI, GitHub and release pages — never estimated." | |
| 43 | + lede="Training frameworks, inference engines, serving stacks, libraries, runtimes, agent frameworks, orchestration and eval harnesses. Versions and stars are read from PyPI, GitHub and release pages — never estimated." | |
| 20 | 44 | basePath="/frameworks" |
| 21 | 45 | searchParams={sp} |
| 22 | 46 | fetch={(q) => api.explore('framework', q)} |
@@ -28,7 +52,17 @@ export default async function FrameworksPage({ searchParams }: { searchParams: P | ||
| 28 | 52 | { value: 'quality', label: 'Data quality' }, |
| 29 | 53 | ]} |
| 30 | 54 | filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []} |
| 55 | + connectors={['github (repositories)', 'pypi', 'release feeds']} | |
| 31 | 56 | compare |
| 57 | + enrich={async (items) => { | |
| 58 | + const hist = await Promise.all(items.map((e) => safe(api.entityHistory(e.slug, 'metric.stars')))); | |
| 59 | + const m = new Map<string, Extra>(); | |
| 60 | + hist.forEach((h, i) => { | |
| 61 | + const vals = (h?.items ?? []).slice().sort((a, b) => a.observed_at.localeCompare(b.observed_at)).map((c) => num(c.value)).filter((v): v is number => v !== null); | |
| 62 | + m.set(items[i]!.slug, { stars: vals }); | |
| 63 | + }); | |
| 64 | + return m; | |
| 65 | + }} | |
| 32 | 66 | columns={[ |
| 33 | 67 | { |
| 34 | 68 | key: 'name', |
@@ -36,35 +70,36 @@ export default async function FrameworksPage({ searchParams }: { searchParams: P | ||
| 36 | 70 | primary: true, |
| 37 | 71 | render: (e) => ( |
| 38 | 72 | <> |
| 39 | − <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 40 | − <EntityLink e={e} /> | |
| 41 | − {e.entity_type !== 'framework' && <EntityBadge type={e.entity_type} small />} | |
| 42 | − </span> | |
| 73 | + <EntityLink e={e} /> | |
| 43 | 74 | {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>} |
| 44 | 75 | </> |
| 45 | 76 | ), |
| 46 | 77 | }, |
| 47 | − { key: 'version', label: 'Latest version', className: 'mono text-xs text-ink', render: (e) => str(e.attributes?.latest_version) ?? <Dash /> }, | |
| 48 | − { key: 'release', label: 'Release date', className: 'tnum text-ink-2 whitespace-nowrap', render: (e) => (str(e.attributes?.latest_release_at) ? fmtDate(str(e.attributes.latest_release_at)) : <Dash />) }, | |
| 49 | − { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> }, | |
| 50 | − { key: 'stars', label: 'Stars', num: true, className: 'tnum', render: (e) => (num(e.attributes?.['metric.stars']) === null ? <Dash /> : fmtInt(e.attributes['metric.stars'])) }, | |
| 51 | − { key: 'language', label: 'Language', className: 'text-ink-2', render: (e) => str(e.attributes?.language) ?? <Dash /> }, | |
| 78 | + { key: 'kind', label: 'Kind', render: (e) => <Chip>{kindOf(e)}</Chip> }, | |
| 79 | + { key: 'version', label: 'Latest release', className: 'mono text-xs text-ink', render: (e) => (str(e.attributes?.latest_version) ? <>{(str(e.attributes.latest_version) as string).slice(0, 24)}{str(e.attributes?.latest_release_at) && <span className="tnum block font-sans text-[11px] text-ink-3">{fmtDate(str(e.attributes.latest_release_at))}</span>}</> : <Dash />) }, | |
| 80 | + { key: 'license', label: 'Licence', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> }, | |
| 52 | 81 | { |
| 53 | − key: 'org', | |
| 54 | − label: 'Organization', | |
| 55 | − className: 'text-ink-2', | |
| 56 | − render: (e) => | |
| 57 | − e.organization ? ( | |
| 58 | − <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent"> | |
| 59 | − {e.organization.name} | |
| 60 | − </Link> | |
| 61 | − ) : ( | |
| 62 | − <Dash /> | |
| 63 | − ), | |
| 82 | + key: 'stars', | |
| 83 | + label: 'Stars', | |
| 84 | + num: true, | |
| 85 | + className: 'tnum', | |
| 86 | + render: (e, x) => { | |
| 87 | + const v = num(e.attributes?.['metric.stars']); | |
| 88 | + if (v === null) return <Dash />; | |
| 89 | + const distinct = new Set(x?.stars ?? []).size; | |
| 90 | + return ( | |
| 91 | + <span className="inline-flex items-center justify-end gap-2"> | |
| 92 | + {x && x.stars.length >= 2 && distinct >= 2 && <Sparkline values={x.stars} width={64} height={18} stroke="var(--type-framework)" title={`Stars history · ${x.stars.length} observations`} />} | |
| 93 | + <span>{fmtInt(v)}</span> | |
| 94 | + </span> | |
| 95 | + ); | |
| 96 | + }, | |
| 64 | 97 | }, |
| 98 | + { key: 'language', label: 'Language', className: 'text-ink-2', render: (e) => str(e.attributes?.language) ?? <Dash /> }, | |
| 99 | + { key: 'org', label: 'Organization', className: 'text-ink-2', render: (e) => (e.organization ? <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">{e.organization.name}</Link> : <Dash />) }, | |
| 65 | 100 | { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> }, |
| 66 | 101 | ]} |
| 67 | − note="Stars are a GitHub metric observed at crawl time (see each framework's Sources tab for the snapshot). Versions come from PyPI or the project's release feed." | |
| 102 | + note="Stars are a GitHub metric observed at crawl time; the sparkline appears once the claim history holds two distinct values (see each framework's History tab). Kind falls back to the entity type (framework · library · runtime) when no source states a finer category." | |
| 68 | 103 | /> |
| 69 | 104 | ); |
| 70 | 105 | } |
added
apps/web/src/app/frontier/loading.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { Container } from '@/components/ui/section'; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <Container wide className="pb-16 pt-7 md:pt-10" aria-busy="true"> | |
| 6 | + <div className="h-3 w-64 animate-pulse bg-surface-2" /> | |
| 7 | + <div className="mt-3 h-9 w-48 animate-pulse bg-surface-2" /> | |
| 8 | + <div className="mt-6 flex gap-2 border-b border-rule pb-2"> | |
| 9 | + {Array.from({ length: 7 }).map((_, i) => ( | |
| 10 | + <div key={i} className="h-6 w-24 animate-pulse bg-surface-2" /> | |
| 11 | + ))} | |
| 12 | + </div> | |
| 13 | + <ul className="mt-8 border-t border-rule"> | |
| 14 | + {Array.from({ length: 10 }).map((_, i) => ( | |
| 15 | + <li key={i} className="border-b border-rule py-3"> | |
| 16 | + <div className="h-4 w-3/4 animate-pulse bg-surface-2" /> | |
| 17 | + </li> | |
| 18 | + ))} | |
| 19 | + </ul> | |
| 20 | + <p className="sr-only">Loading the frontier…</p> | |
| 21 | + </Container> | |
| 22 | + ); | |
| 23 | +} | |
added
apps/web/src/app/frontier/opengraph-image.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { intel, safe } from '@/lib/api'; | |
| 4 | +import { fmtInt, fmtScore } from '@/lib/format'; | |
| 5 | +import { SITE_NAME } from '@/lib/site'; | |
| 6 | + | |
| 7 | +export const runtime = 'nodejs'; | |
| 8 | +export const alt = `The AI frontier right now on ${SITE_NAME}`; | |
| 9 | +export const size = { width: 1200, height: 630 }; | |
| 10 | +export const contentType = 'image/png'; | |
| 11 | + | |
| 12 | +export default async function FrontierOgImage() { | |
| 13 | + const f = await safe(intel.frontier(3)); | |
| 14 | + const counters: [string, string][] = []; | |
| 15 | + if (f) { | |
| 16 | + counters.push(['Benchmarks tracked', fmtInt(f.benchmark_frontier?.length ?? 0)]); | |
| 17 | + counters.push(['Frontier models', fmtInt(f.price_frontier?.frontier_models)]); | |
| 18 | + const lead = f.benchmark_frontier?.[0]?.leader; | |
| 19 | + if (lead) counters.push([`Leads ${f.benchmark_frontier?.[0]?.benchmark.name ?? ''}`.slice(0, 28), `${lead.model.name} · ${fmtScore(lead.score)}`.slice(0, 30)]); | |
| 20 | + counters.push(['Pareto set', fmtInt(f.efficiency_frontier?.frontier?.length ?? 0)]); | |
| 21 | + } | |
| 22 | + return new ImageResponse(<Wallpaper eyebrow="What defines the AI frontier right now" title="Frontier" subtitle="Leaders per benchmark, cheapest frontier offers, largest contexts, open weights, quality vs price — observed dimensions, no composite score." counters={counters} footer="www.ai-atlas.co/frontier" markPx={240} />, { ...size }); | |
| 23 | +} | |
added
apps/web/src/app/frontier/page.tsx
+435 −0
@@ -0,0 +1,435 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import type { ScatterPoint } from '@/components/charts'; | |
| 4 | +import { EfficiencyScatter } from '@/components/intelligence/client-charts'; | |
| 5 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 6 | +import { DistBars, Methodology, RankChips, TrustChip } from '@/components/intelligence/bits'; | |
| 7 | +import { SectionNav, Ticker, type TickerItem } from '@/components/layout/terminal'; | |
| 8 | +import { Chip, OpennessBadge } from '@/components/ui/badges'; | |
| 9 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 10 | +import { EntityLink } from '@/components/ui/entity'; | |
| 11 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 12 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 13 | +import { intel } from '@/lib/api'; | |
| 14 | +import { safe } from '@/lib/api'; | |
| 15 | +import { fmtDate, fmtDateTime, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, num } from '@/lib/format'; | |
| 16 | +import { eventTone, routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 17 | +import { eventDate, type Deployment, type EntitySummary, type ModelRef } from '@/lib/types'; | |
| 18 | + | |
| 19 | +export const revalidate = 300; | |
| 20 | + | |
| 21 | +const TITLE = 'The AI frontier right now — leaders per benchmark, price, context and open weights'; | |
| 22 | +const DESC = 'What defines the AI frontier today: the latest major models, the leader and runner-up of every benchmark comparability group, the cheapest frontier offers, the largest context windows, the open-weight frontier, the quality-vs-price Pareto set, agentic and multimodal leaders and recent movements — observed dimensions only, no composite score.'; | |
| 23 | +export const metadata: Metadata = { | |
| 24 | + title: TITLE, | |
| 25 | + description: DESC, | |
| 26 | + alternates: { canonical: routes.frontier() }, | |
| 27 | + openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.frontier()}`, type: 'website', siteName: SITE_NAME }, | |
| 28 | + twitter: { card: 'summary_large_image', title: TITLE, description: DESC }, | |
| 29 | +}; | |
| 30 | + | |
| 31 | +const NAV = [ | |
| 32 | + { id: 'latest', label: 'Latest major models' }, | |
| 33 | + { id: 'benchmarks', label: 'Benchmark frontier' }, | |
| 34 | + { id: 'price', label: 'Price frontier' }, | |
| 35 | + { id: 'context', label: 'Context frontier' }, | |
| 36 | + { id: 'open', label: 'Open-weight frontier' }, | |
| 37 | + { id: 'efficiency', label: 'Efficiency frontier' }, | |
| 38 | + { id: 'agentic', label: 'Agentic' }, | |
| 39 | + { id: 'multimodal', label: 'Multimodal' }, | |
| 40 | + { id: 'movements', label: 'Recent movements' }, | |
| 41 | +]; | |
| 42 | + | |
| 43 | +const modelHref = (m: { slug: string; entity_type?: string }) => routes.entity({ entity_type: m.entity_type ?? 'model', slug: m.slug }); | |
| 44 | +const orgName = (m: ModelRef | EntitySummary) => m.organization?.name ?? null; | |
| 45 | + | |
| 46 | +function DeploymentStrip({ d, label }: { d: Deployment | null | undefined; label: string }) { | |
| 47 | + return ( | |
| 48 | + <div className="min-w-0 border-b border-rule py-3 md:border-b-0 md:border-r md:pr-6 last:md:border-r-0"> | |
| 49 | + <p className="eyebrow">{label}</p> | |
| 50 | + {d && d.model ? ( | |
| 51 | + <> | |
| 52 | + <p className="mt-1 flex flex-wrap items-baseline gap-x-2"> | |
| 53 | + <EntityLink e={d.model} className="text-[15px] font-medium" /> | |
| 54 | + {d.model.organization && <span className="text-xs text-ink-3">{d.model.organization.name}</span>} | |
| 55 | + </p> | |
| 56 | + <p className="tnum mt-1 text-[22px] font-semibold leading-none text-accent-2"> | |
| 57 | + {fmtUsdPerM(d.prices.output)} <span className="text-xs font-normal text-ink-3">output / 1M</span> | |
| 58 | + </p> | |
| 59 | + <p className="tnum mt-1 text-xs text-ink-3"> | |
| 60 | + input {fmtUsdPerM(d.prices.input)} · context {fmtTokens(d.context_length)} · via <EntityLink e={d.provider} className="text-ink-2" /> · observed {fmtDate(d.observed_at)} | |
| 61 | + </p> | |
| 62 | + </> | |
| 63 | + ) : ( | |
| 64 | + <p className="mt-1 text-sm text-ink-3">No frontier model has a current priced offer in the API response.</p> | |
| 65 | + )} | |
| 66 | + </div> | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +export default async function FrontierPage() { | |
| 71 | + const [f, idx] = await Promise.all([safe(intel.frontier(12)), safe(intel.priceIndex(30))]); | |
| 72 | + if (!f) { | |
| 73 | + return ( | |
| 74 | + <Container wide> | |
| 75 | + <PageHeader eyebrow="What defines the AI frontier right now" title="Frontier" lede="Observed leaders per dimension — benchmarks, price, context, open weights, efficiency — from the atlas. No composite score." /> | |
| 76 | + <Unavailable what="Frontier" reason="GET /frontier did not answer. Nothing here is cached or hardcoded." /> | |
| 77 | + </Container> | |
| 78 | + ); | |
| 79 | + } | |
| 80 | + const latest = f.latest_major_models ?? []; | |
| 81 | + const bench = f.benchmark_frontier ?? []; | |
| 82 | + const pf = f.price_frontier; | |
| 83 | + const ctx = f.context_frontier ?? []; | |
| 84 | + const open = f.open_weight_frontier; | |
| 85 | + const eff = f.efficiency_frontier; | |
| 86 | + const agentic = f.agentic_frontier ?? []; | |
| 87 | + const multi = f.multimodal_frontier ?? []; | |
| 88 | + const moves = f.recent_frontier_movements ?? []; | |
| 89 | + const compo = pf?.composition ?? f.price_frontier?.composition ?? null; | |
| 90 | + | |
| 91 | + // efficiency scatter | |
| 92 | + const points: ScatterPoint[] = (eff?.points ?? []) | |
| 93 | + .filter((p) => num(p.x) !== null && num(p.y) !== null) | |
| 94 | + .map((p) => ({ id: p.id, x: num(p.x) as number, y: num(p.y) as number, label: p.model.name, sub: `${p.model.organization ?? ''}${p.provider ? ` · via ${p.provider.name}` : ''} · rank ${p.rank}`, href: modelHref(p.model), color: p.pareto ? 'var(--accent-2)' : undefined })); | |
| 95 | + const frontierSet = new Set(eff?.frontier ?? []); | |
| 96 | + const frontierLine = points.filter((p) => frontierSet.has(p.id)).sort((a, b) => a.x - b.x); | |
| 97 | + const ticker: TickerItem[] = moves.slice(0, 24).map((e) => ({ id: e.id, tone: eventTone(e.event_type), href: e.entity ? routes.entity(e.entity) : routes.changes(), label: e.summary.length > 100 ? `${e.summary.slice(0, 99)}…` : e.summary, meta: num(e.percent_change) !== null ? `${(num(e.percent_change) as number) > 0 ? '+' : ''}${(num(e.percent_change) as number).toFixed(0)}%` : fmtDate(eventDate(e)) })); | |
| 98 | + const ld = { | |
| 99 | + '@context': 'https://schema.org', | |
| 100 | + '@type': 'Dataset', | |
| 101 | + name: 'AI Atlas Frontier', | |
| 102 | + description: DESC, | |
| 103 | + url: `${SITE_URL}${routes.frontier()}`, | |
| 104 | + dateModified: f.generated_at, | |
| 105 | + creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL }, | |
| 106 | + isAccessibleForFree: true, | |
| 107 | + }; | |
| 108 | + | |
| 109 | + return ( | |
| 110 | + <Container wide> | |
| 111 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 112 | + <PageHeader eyebrow="What defines the AI frontier right now" title="Frontier" lede="Who leads, on what, since when — one observed dimension at a time. Every leader comes from a benchmark comparability group, a published price or a sourced attribute; the atlas never sums them into a single score." aside={f.generated_at ? <p className="tnum text-sm text-ink-3" title={f.generated_at}>computed {fmtDateTime(f.generated_at)}</p> : undefined} /> | |
| 113 | + <SectionNav items={NAV} /> | |
| 114 | + {ticker.length > 0 && <Ticker items={ticker} className="mt-3" label="Movements" />} | |
| 115 | + | |
| 116 | + {/* ---------------------------------------------------------------------------------------------- latest */} | |
| 117 | + <Section id="latest" eyebrow="Latest major models" title="Most recent importance-3 model events" lede="New canonical model releases, newest first. Facts are the model's sourced attributes at crawl time." action={{ href: `${routes.changes()}?type=NEW_MODEL&importance_min=3`, label: 'All new models' }} hairline={false}> | |
| 118 | + {latest.length === 0 ? ( | |
| 119 | + <EmptyState title="No major model event returned">The API returned an empty list for latest_major_models.</EmptyState> | |
| 120 | + ) : ( | |
| 121 | + <DataTable caption="Latest major models"> | |
| 122 | + <thead> | |
| 123 | + <tr> | |
| 124 | + <Th>Model</Th> | |
| 125 | + <Th>Organization</Th> | |
| 126 | + <Th>Released</Th> | |
| 127 | + <Th>Key facts</Th> | |
| 128 | + <Th>Evidence</Th> | |
| 129 | + </tr> | |
| 130 | + </thead> | |
| 131 | + <tbody> | |
| 132 | + {latest.map((e) => { | |
| 133 | + const m = e.entity; | |
| 134 | + const a = m?.attributes ?? {}; | |
| 135 | + return ( | |
| 136 | + <tr key={e.id}> | |
| 137 | + <Td primary> | |
| 138 | + {m ? <EntityLink e={m} /> : <span>{e.summary}</span>} | |
| 139 | + {typeof a.openness === 'string' && <OpennessBadge openness={a.openness} className="ml-2" />} | |
| 140 | + </Td> | |
| 141 | + <Td label="Organization" className="text-ink-2">{m?.organization ? <Link href={routes.entity({ entity_type: 'company', slug: m.organization.slug })} className="hover:text-accent">{m.organization.name}</Link> : '—'}</Td> | |
| 142 | + <Td label="Released" className="tnum text-ink-2 whitespace-nowrap">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : fmtDate(eventDate(e))}</Td> | |
| 143 | + <Td label="Key facts" className="tnum text-xs text-ink-2"> | |
| 144 | + {[num(a.parameter_count) !== null ? `${fmtParams(a.parameter_count)} params` : null, num(a.context_length) !== null ? `${fmtTokens(a.context_length)} context` : null, Array.isArray(a.modalities) && a.modalities.length ? (a.modalities as string[]).join(' · ') : null, typeof a.license === 'string' ? a.license : null].filter(Boolean).join(' · ') || '—'} | |
| 145 | + </Td> | |
| 146 | + <Td label="Evidence" className="text-xs"> | |
| 147 | + {e.source_url ? ( | |
| 148 | + <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="text-ink-2 hover:text-accent"> | |
| 149 | + {new URL(e.source_url).hostname.replace(/^www\./, '')} | |
| 150 | + </a> | |
| 151 | + ) : ( | |
| 152 | + <span className="text-ink-3">—</span> | |
| 153 | + )} | |
| 154 | + {e.connector_name && <span className="mono ml-1.5 text-[10px] text-ink-3">{e.connector_name}</span>} | |
| 155 | + {e.is_backfill && <span className="ml-1.5 text-[10px] text-ink-3">(back-filled date)</span>} | |
| 156 | + </Td> | |
| 157 | + </tr> | |
| 158 | + ); | |
| 159 | + })} | |
| 160 | + </tbody> | |
| 161 | + </DataTable> | |
| 162 | + )} | |
| 163 | + </Section> | |
| 164 | + | |
| 165 | + {/* ------------------------------------------------------------------------------------------ benchmarks */} | |
| 166 | + <Section id="benchmarks" eyebrow="Benchmark frontier" title="Leader and runner-up per benchmark" lede="One row per benchmark with ≥ 20 current results: the primary comparability group, its leader, the second model and the gap between them, with the trust level of the leading row." action={{ href: routes.benchmarks(), label: 'All leaderboards' }}> | |
| 167 | + {bench.length === 0 ? ( | |
| 168 | + <EmptyState title="No benchmark group qualifies yet">The frontier lists groups with at least 20 current results.</EmptyState> | |
| 169 | + ) : ( | |
| 170 | + <DataTable caption="Benchmark frontier"> | |
| 171 | + <thead> | |
| 172 | + <tr> | |
| 173 | + <Th>Benchmark</Th> | |
| 174 | + <Th>Leader</Th> | |
| 175 | + <Th num>Score</Th> | |
| 176 | + <Th>Second</Th> | |
| 177 | + <Th num>Gap</Th> | |
| 178 | + <Th>Trust</Th> | |
| 179 | + </tr> | |
| 180 | + </thead> | |
| 181 | + <tbody> | |
| 182 | + {bench.map((b) => ( | |
| 183 | + <tr key={b.benchmark.id}> | |
| 184 | + <Td primary> | |
| 185 | + <Link href={routes.benchmark(b.benchmark.slug)} className="text-ink hover:text-accent hover:underline"> | |
| 186 | + {b.benchmark.name} | |
| 187 | + </Link> | |
| 188 | + {b.group && <span className="mono block text-[11px] text-ink-3">{b.group.label} · n={fmtInt(b.group.model_count)} models</span>} | |
| 189 | + </Td> | |
| 190 | + <Td label="Leader"> | |
| 191 | + {b.leader ? ( | |
| 192 | + <> | |
| 193 | + <Link href={modelHref(b.leader.model)} className="font-medium text-ink hover:text-accent hover:underline"> | |
| 194 | + {b.leader.model.name} | |
| 195 | + </Link> | |
| 196 | + {orgName(b.leader.model) && <span className="ml-1.5 text-xs text-ink-3">{orgName(b.leader.model)}</span>} | |
| 197 | + </> | |
| 198 | + ) : ( | |
| 199 | + '—' | |
| 200 | + )} | |
| 201 | + </Td> | |
| 202 | + <Td num label="Score" className="tnum font-medium">{b.leader ? `${fmtScore(b.leader.score)}${b.leader.unit ? ` ${b.leader.unit}` : ''}` : '—'}</Td> | |
| 203 | + <Td label="Second" className="text-ink-2"> | |
| 204 | + {b.second ? ( | |
| 205 | + <> | |
| 206 | + <Link href={modelHref(b.second.model)} className="hover:text-accent"> | |
| 207 | + {b.second.model.name} | |
| 208 | + </Link>{' '} | |
| 209 | + <span className="tnum text-xs text-ink-3">{fmtScore(b.second.score)}</span> | |
| 210 | + </> | |
| 211 | + ) : ( | |
| 212 | + '—' | |
| 213 | + )} | |
| 214 | + </Td> | |
| 215 | + <Td num label="Gap" className="tnum text-ink-2">{num(b.gap) === null ? '—' : fmtScore(b.gap)}</Td> | |
| 216 | + <Td label="Trust"> | |
| 217 | + <TrustChip level={b.leader?.trust_level} label={b.leader?.trust_label} /> | |
| 218 | + </Td> | |
| 219 | + </tr> | |
| 220 | + ))} | |
| 221 | + </tbody> | |
| 222 | + </DataTable> | |
| 223 | + )} | |
| 224 | + <Methodology text="Leader = best current row of the primary comparability group (canonical metric × task-defining configuration), one row per canonical model. Gap = leader − second in the metric's unit." /> | |
| 225 | + </Section> | |
| 226 | + | |
| 227 | + {/* ----------------------------------------------------------------------------------------------- price */} | |
| 228 | + <Section id="price" eyebrow="Price frontier" title="Cheapest frontier output" lede="Among frontier models, the cheapest current published output price — and the cheapest with a context window of at least 1M tokens — with the provider that publishes it." action={{ href: `${routes.prices()}?sort=cheapest_frontier`, label: 'Price terminal' }}> | |
| 229 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]"> | |
| 230 | + <DeploymentStrip d={pf?.cheapest_output} label="Cheapest frontier output" /> | |
| 231 | + <DeploymentStrip d={pf?.cheapest_output_1m_context} label="Cheapest with ≥ 1M context" /> | |
| 232 | + <div className="py-3 md:pl-6"> | |
| 233 | + <p className="eyebrow">Current offers by output price</p> | |
| 234 | + <DistBars d={idx?.distribution} className="mt-1" /> | |
| 235 | + </div> | |
| 236 | + </div> | |
| 237 | + <p className="tnum mt-2 text-xs text-ink-3"> | |
| 238 | + Frontier universe: {fmtInt(pf?.frontier_models)} canonical models | |
| 239 | + {compo && ( | |
| 240 | + <> | |
| 241 | + {' '} | |
| 242 | + ({fmtInt(compo.recent_by_active_orgs)} recent releases by active organizations · {fmtInt(compo.top10_on_a_benchmark)} top-10 on a benchmark | |
| 243 | + {compo.since ? ` · since ${fmtDate(compo.since)}` : ''}) | |
| 244 | + </> | |
| 245 | + )} | |
| 246 | + . | |
| 247 | + </p> | |
| 248 | + <Methodology text={idx?.frontier?.methodology ?? f.methodology} /> | |
| 249 | + </Section> | |
| 250 | + | |
| 251 | + {/* --------------------------------------------------------------------------------------------- context */} | |
| 252 | + <Section id="context" eyebrow="Context frontier" title="Largest context windows" lede="Distinct canonical models with the largest sourced context_length. A routing endpoint counts as a model only if its provider publishes it as one." action={{ href: `${routes.models()}?sort=context`, label: 'Models by context' }}> | |
| 253 | + {ctx.length === 0 ? ( | |
| 254 | + <EmptyState title="No context data returned" /> | |
| 255 | + ) : ( | |
| 256 | + <ol className="grid gap-x-6 md:grid-cols-2 xl:grid-cols-3"> | |
| 257 | + {ctx.map((c, i) => ( | |
| 258 | + <li key={c.model.id} className="flex items-baseline gap-3 border-b border-rule py-2.5"> | |
| 259 | + <span className="tnum w-5 shrink-0 text-xs text-ink-3">{i + 1}</span> | |
| 260 | + <span className="min-w-0 flex-1"> | |
| 261 | + <EntityLink e={c.model} className="font-medium" /> | |
| 262 | + {c.model.organization && <span className="ml-1.5 text-xs text-ink-3">{c.model.organization.name}</span>} | |
| 263 | + </span> | |
| 264 | + <span className="tnum shrink-0 text-[15px] font-semibold">{fmtTokens(c.context_length)}</span> | |
| 265 | + </li> | |
| 266 | + ))} | |
| 267 | + </ol> | |
| 268 | + )} | |
| 269 | + </Section> | |
| 270 | + | |
| 271 | + {/* ------------------------------------------------------------------------------------------------ open */} | |
| 272 | + <Section id="open" eyebrow="Open-weight frontier" title="Best-ranked downloadable models" lede="Observed dimensions only — best rank on any benchmark, parameters, context — for models whose weights can be downloaded. Sorted by best rank, then parameters." action={{ href: routes.open(), label: 'Open model frontier' }}> | |
| 273 | + {!open || open.items.length === 0 ? ( | |
| 274 | + <EmptyState title="No open-weight model with a current benchmark rank" /> | |
| 275 | + ) : ( | |
| 276 | + <DataTable caption="Open-weight frontier"> | |
| 277 | + <thead> | |
| 278 | + <tr> | |
| 279 | + <Th>Model</Th> | |
| 280 | + <Th num>Best rank</Th> | |
| 281 | + <Th>On</Th> | |
| 282 | + <Th num>Parameters</Th> | |
| 283 | + <Th num>Context</Th> | |
| 284 | + <Th>Other ranks</Th> | |
| 285 | + </tr> | |
| 286 | + </thead> | |
| 287 | + <tbody> | |
| 288 | + {open.items.map((it) => ( | |
| 289 | + <tr key={it.model.id}> | |
| 290 | + <Td primary> | |
| 291 | + <EntityLink e={it.model} /> | |
| 292 | + {it.model.organization && <span className="ml-2 text-xs text-ink-3">{it.model.organization.name}</span>} | |
| 293 | + {typeof it.model.attributes?.license === 'string' && <span className="block text-[11px] text-ink-3">{it.model.attributes.license as string}</span>} | |
| 294 | + </Td> | |
| 295 | + <Td num label="Best rank" className="tnum font-semibold">{num(it.best_rank) === null ? '—' : `#${fmtInt(it.best_rank)}`}</Td> | |
| 296 | + <Td label="On">{it.best_rank_on ? <Link href={routes.benchmark(it.best_rank_on)} className="text-ink-2 hover:text-accent">{it.best_rank_on}</Link> : '—'}</Td> | |
| 297 | + <Td num label="Parameters" className="tnum">{fmtParams(it.parameter_count)}</Td> | |
| 298 | + <Td num label="Context" className="tnum">{fmtTokens(it.context_length)}</Td> | |
| 299 | + <Td label="Other ranks"><RankChips ranks={it.ranks} max={3} /></Td> | |
| 300 | + </tr> | |
| 301 | + ))} | |
| 302 | + </tbody> | |
| 303 | + </DataTable> | |
| 304 | + )} | |
| 305 | + <Methodology text={`Dimensions: ${(open?.dimensions ?? []).join(', ') || 'best_rank, parameter_count, context_length'}. ${open?.note ?? ''}`} /> | |
| 306 | + </Section> | |
| 307 | + | |
| 308 | + {/* ------------------------------------------------------------------------------------------- efficiency */} | |
| 309 | + <Section id="efficiency" eyebrow="Efficiency frontier" title={eff?.quality?.benchmark ? <>Quality vs output price · {eff.quality.benchmark}</> : 'Quality vs output price'} lede={eff ? `y = ${eff.quality.group?.label ?? 'score'} in the primary comparability group; x = ${eff.x}. Amber points form the Pareto set (higher score, lower price); the rest are dimmed.` : undefined} action={eff?.quality?.benchmark ? { href: routes.benchmark(eff.quality.benchmark), label: 'Leaderboard' } : undefined}> | |
| 310 | + {points.length < 2 ? ( | |
| 311 | + <EmptyState title="Not enough priced results for a Pareto view">The efficiency frontier needs models with both a current benchmark result and a current output price.</EmptyState> | |
| 312 | + ) : ( | |
| 313 | + <> | |
| 314 | + <EfficiencyScatter points={points} frontier={frontierLine.map((p) => ({ x: p.x, y: p.y }))} highlight={[...frontierSet]} yLabel={eff?.quality.group?.label ?? 'score'} /> | |
| 315 | + <ul className="mt-3 flex flex-wrap gap-1.5"> | |
| 316 | + {frontierLine.map((p) => ( | |
| 317 | + <li key={p.id}> | |
| 318 | + <Link href={p.href ?? '#'} className="tnum inline-flex items-center gap-1.5 border border-rule px-2 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 319 | + <span className="inline-block size-1.5 rounded-full bg-accent-2" aria-hidden /> {p.label} <span className="text-ink-3">{fmtScore(p.y)} · {fmtUsdPerM(p.x)}</span> | |
| 320 | + </Link> | |
| 321 | + </li> | |
| 322 | + ))} | |
| 323 | + </ul> | |
| 324 | + <p className="tnum mt-2 text-xs text-ink-3"> | |
| 325 | + {fmtInt(points.length)} models plotted · {fmtInt(frontierLine.length)} on the Pareto frontier · group n={fmtInt(eff?.quality.group?.model_count)} | |
| 326 | + </p> | |
| 327 | + </> | |
| 328 | + )} | |
| 329 | + <Methodology text="Pareto frontier maximises the score and minimises the cheapest current output price; exact ties are kept. Price = cheapest current offer across providers. A point's position is two observed facts, not a rating." /> | |
| 330 | + </Section> | |
| 331 | + | |
| 332 | + {/* ---------------------------------------------------------------------------------------------- agentic */} | |
| 333 | + <Section id="agentic" eyebrow="Agentic frontier" title="Leaders on agentic benchmarks" lede="Top rows of the primary comparability group of each agentic benchmark (tool use, terminal tasks, multi-turn agents)."> | |
| 334 | + {agentic.length === 0 ? ( | |
| 335 | + <EmptyState title="No agentic benchmark group returned" /> | |
| 336 | + ) : ( | |
| 337 | + <div className="grid gap-6 md:grid-cols-2"> | |
| 338 | + {agentic.map((g) => ( | |
| 339 | + <div key={g.benchmark.id} className="min-w-0"> | |
| 340 | + <p className="flex flex-wrap items-baseline gap-x-2"> | |
| 341 | + <Link href={routes.benchmark(g.benchmark.slug)} className="text-[15px] font-semibold text-ink hover:text-accent"> | |
| 342 | + {g.benchmark.name} | |
| 343 | + </Link> | |
| 344 | + {g.group && <span className="mono text-[11px] text-ink-3">{g.group.label} · n={fmtInt(g.group.model_count)}</span>} | |
| 345 | + </p> | |
| 346 | + <ol className="mt-1.5 border-t border-rule"> | |
| 347 | + {g.leaders.map((l) => ( | |
| 348 | + <li key={l.result_id} className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-baseline gap-2 border-b border-rule py-2 text-sm"> | |
| 349 | + <span className="tnum text-xs text-ink-3">{l.rank}</span> | |
| 350 | + <span className="min-w-0 truncate"> | |
| 351 | + <Link href={modelHref(l.model)} className="text-ink hover:text-accent hover:underline"> | |
| 352 | + {l.model.name} | |
| 353 | + </Link> | |
| 354 | + {orgName(l.model) && <span className="ml-1.5 text-xs text-ink-3">{orgName(l.model)}</span>} | |
| 355 | + </span> | |
| 356 | + <span className="tnum inline-flex items-center gap-2 text-right"> | |
| 357 | + <span className="font-medium">{fmtScore(l.score)}</span> | |
| 358 | + <TrustChip level={l.trust_level} label={l.trust_label} /> | |
| 359 | + </span> | |
| 360 | + </li> | |
| 361 | + ))} | |
| 362 | + </ol> | |
| 363 | + </div> | |
| 364 | + ))} | |
| 365 | + </div> | |
| 366 | + )} | |
| 367 | + </Section> | |
| 368 | + | |
| 369 | + {/* ------------------------------------------------------------------------------------------- multimodal */} | |
| 370 | + <Section id="multimodal" eyebrow="Multimodal frontier" title="Models with the most modalities among top-10 ranks" lede="Sourced modalities of models that hold a top-10 rank on at least one benchmark. Modality lists are attributes as published, not evaluations."> | |
| 371 | + {multi.length === 0 ? ( | |
| 372 | + <EmptyState title="No multimodal leader returned" /> | |
| 373 | + ) : ( | |
| 374 | + <DataTable caption="Multimodal frontier"> | |
| 375 | + <thead> | |
| 376 | + <tr> | |
| 377 | + <Th>Model</Th> | |
| 378 | + <Th>Modalities</Th> | |
| 379 | + <Th>Top-10 on</Th> | |
| 380 | + </tr> | |
| 381 | + </thead> | |
| 382 | + <tbody> | |
| 383 | + {multi.map((m) => ( | |
| 384 | + <tr key={m.model.id}> | |
| 385 | + <Td primary> | |
| 386 | + <Link href={modelHref(m.model)} className="font-medium text-ink hover:text-accent hover:underline"> | |
| 387 | + {m.model.name} | |
| 388 | + </Link> | |
| 389 | + {'organization' in m.model && m.model.organization && <span className="ml-2 text-xs text-ink-3">{m.model.organization.name}</span>} | |
| 390 | + </Td> | |
| 391 | + <Td label="Modalities"> | |
| 392 | + <span className="flex flex-wrap gap-1"> | |
| 393 | + {m.modalities.map((x) => ( | |
| 394 | + <Chip key={x}>{x}</Chip> | |
| 395 | + ))} | |
| 396 | + </span> | |
| 397 | + </Td> | |
| 398 | + <Td label="Top-10 on" className="text-xs"> | |
| 399 | + <span className="flex flex-wrap gap-1"> | |
| 400 | + {m.top10_on.slice(0, 6).map((b) => ( | |
| 401 | + <Link key={b} href={routes.benchmark(b)} className="rounded-[3px] bg-surface-2 px-1.5 py-[1px] text-[11px] text-ink-2 hover:text-accent"> | |
| 402 | + {b} | |
| 403 | + </Link> | |
| 404 | + ))} | |
| 405 | + {m.top10_on.length > 6 && <span className="text-[11px] text-ink-3">+{m.top10_on.length - 6}</span>} | |
| 406 | + </span> | |
| 407 | + </Td> | |
| 408 | + </tr> | |
| 409 | + ))} | |
| 410 | + </tbody> | |
| 411 | + </DataTable> | |
| 412 | + )} | |
| 413 | + </Section> | |
| 414 | + | |
| 415 | + {/* -------------------------------------------------------------------------------------------- movements */} | |
| 416 | + <Section id="movements" eyebrow="Recent frontier movements" title="Leadership changes and price moves ≥ 20 %" lede="Non-backfill benchmark events and price changes of at least 20 % over the last 30 days, newest first." action={{ href: routes.changes(), label: 'All changes' }}> | |
| 417 | + {moves.length === 0 ? ( | |
| 418 | + <EmptyState title="No frontier movement in the last 30 days"> | |
| 419 | + The atlas has not observed a leadership change or a ≥ 20 % price move that occurred in the window (historical backfill is excluded). The <Link href={routes.changes()} className="link">change feed</Link> lists everything else. | |
| 420 | + </EmptyState> | |
| 421 | + ) : ( | |
| 422 | + <ul className="border-t border-rule"> | |
| 423 | + {moves.map((e) => ( | |
| 424 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 425 | + ))} | |
| 426 | + </ul> | |
| 427 | + )} | |
| 428 | + <Methodology text={f.methodology} /> | |
| 429 | + <Note className="mt-2"> | |
| 430 | + Composition and every threshold above are the API's (<Link href="/developers" className="link">GET /frontier</Link>); this page adds no ranking of its own. | |
| 431 | + </Note> | |
| 432 | + </Section> | |
| 433 | + </Container> | |
| 434 | + ); | |
| 435 | +} | |
added
apps/web/src/app/hardware/[slug]/opengraph-image.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Fallback, Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { fmtMemory } from '@/components/hardware/hardware-bits'; | |
| 4 | +import { api, intel, safe } from '@/lib/api'; | |
| 5 | +import { fmtInt, num } from '@/lib/format'; | |
| 6 | +import { SITE_NAME } from '@/lib/site'; | |
| 7 | + | |
| 8 | +export const runtime = 'nodejs'; | |
| 9 | +export const alt = `Hardware on ${SITE_NAME}`; | |
| 10 | +export const size = { width: 1200, height: 630 }; | |
| 11 | +export const contentType = 'image/png'; | |
| 12 | + | |
| 13 | +export default async function HardwareOgImage({ params }: { params: Promise<{ slug: string }> }) { | |
| 14 | + const { slug } = await params; | |
| 15 | + const [d, fit] = await Promise.all([safe(api.entityOfType('hardware', slug)), safe(intel.hardwareSlugFit(slug, { limit: 1 }))]); | |
| 16 | + if (!d || d.entity_type !== 'hardware') return new ImageResponse(<Fallback label="Hardware" />, { ...size }); | |
| 17 | + const a = d.attributes ?? {}; | |
| 18 | + const counters: [string, string][] = [['Memory', fmtMemory(a.memory_gb)]]; | |
| 19 | + if (num(a.memory_bandwidth_gbs) !== null) counters.push(['Bandwidth', `${fmtInt(a.memory_bandwidth_gbs)} GB/s`]); | |
| 20 | + if (num(a.tdp_watts) !== null) counters.push(['TDP', `${fmtInt(a.tdp_watts)} W`]); | |
| 21 | + if (fit) counters.push(['Fit @4bit (est.)', `${fmtInt(fit.counts.fits)} / ${fmtInt(fit.counts.evaluated)}`]); | |
| 22 | + return new ImageResponse(<Wallpaper eyebrow={typeof a.manufacturer === 'string' ? `Hardware · ${a.manufacturer}` : 'Hardware'} title={d.name} subtitle="Published specifications with sources, and the models estimated to fit at 4-bit, 8-bit and fp16." counters={counters} footer={`www.ai-atlas.co/hardware/${slug}`} markPx={220} />, { ...size }); | |
| 23 | +} | |
added
apps/web/src/app/hardware/[slug]/page.tsx
+229 −0
@@ -0,0 +1,229 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import type { Metadata } from 'next'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { permanentRedirect } from 'next/navigation'; | |
| 5 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 6 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 7 | +import { RelationsBlock, SourcesTable, TimelineList } from '@/components/entity/blocks'; | |
| 8 | +import { buildMetadata, loadEntity } from '@/components/entity/load'; | |
| 9 | +import { QUANTS } from '@/components/hardware/fit-form'; | |
| 10 | +import { fmtMemory, HW_KIND_LABELS, memoryOptions } from '@/components/hardware/hardware-bits'; | |
| 11 | +import { EstimateBanner, FitBreakdownList, Methodology, SourceTag } from '@/components/intelligence/bits'; | |
| 12 | +import { ViewBeacon } from '@/components/layout/view-beacon'; | |
| 13 | +import { Chip, EntityBadge, Estimated, OpennessBadge, StatusBadge } from '@/components/ui/badges'; | |
| 14 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 15 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 16 | +import { KeyValue } from '@/components/ui/key-value'; | |
| 17 | +import { Container, Note, Section } from '@/components/ui/section'; | |
| 18 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 19 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 20 | +import { api, intel, safe } from '@/lib/api'; | |
| 21 | +import { fmtGb, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 22 | +import { PROSE_KEYS, routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 23 | + | |
| 24 | +export const revalidate = 600; | |
| 25 | +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<Record<string, string | undefined>> }; | |
| 26 | +const CONTEXTS = [8192, 32768, 131072]; | |
| 27 | + | |
| 28 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 29 | + const { slug } = await params; | |
| 30 | + const d = await safe(api.entityOfType('hardware', slug)); | |
| 31 | + if (!d || d.entity_type !== 'hardware') return { title: 'Hardware', robots: { index: false } }; | |
| 32 | + const m = buildMetadata(d); | |
| 33 | + const a = d.attributes ?? {}; | |
| 34 | + return { ...m, title: `${d.name} — specs and which AI models fit (estimated)`, description: `${d.name}${typeof a.manufacturer === 'string' ? ` by ${a.manufacturer}` : ''}: ${fmtMemory(a.memory_gb)} memory${num(a.memory_bandwidth_gbs) !== null ? `, ${fmtInt(a.memory_bandwidth_gbs)} GB/s` : ''} as published, with sources — and the models estimated to fit at 4-bit, 8-bit and fp16. ${SITE_NAME}.` }; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export default async function HardwarePage({ params, searchParams }: Params) { | |
| 38 | + const { slug } = await params; | |
| 39 | + const sp = await searchParams; | |
| 40 | + const d = await loadEntity('hardware', slug); | |
| 41 | + const canonical = routes.entity(d); | |
| 42 | + if (canonical !== `/hardware/${encodeURIComponent(slug)}`) permanentRedirect(canonical); | |
| 43 | + const a = d.attributes ?? {}; | |
| 44 | + const quant = QUANTS.some((q) => q.value === sp.quant) ? (sp.quant as string) : '4bit'; | |
| 45 | + const ctx = num(sp.context); | |
| 46 | + const context = ctx !== null && ctx > 0 ? Math.round(ctx) : 8192; | |
| 47 | + const memChoice = num(sp.memory_gb); | |
| 48 | + const options = memoryOptions(a.memory_gb); | |
| 49 | + const fit = await safe(intel.hardwareSlugFit(d.slug, { quant, context, memory_gb: memChoice ?? undefined, limit: 120 })); | |
| 50 | + const memoryUsed = num(fit?.inputs?.memory_gb) ?? memChoice ?? options.at(-1) ?? null; | |
| 51 | + const self = (patch: { quant?: string; context?: number; memory_gb?: number | null }) => { | |
| 52 | + const p = new URLSearchParams(); | |
| 53 | + const q = patch.quant ?? quant; | |
| 54 | + const c = patch.context ?? context; | |
| 55 | + const m = patch.memory_gb === undefined ? memChoice : patch.memory_gb; | |
| 56 | + if (q !== '4bit') p.set('quant', q); | |
| 57 | + if (c !== 8192) p.set('context', String(c)); | |
| 58 | + if (m !== null && m !== undefined) p.set('memory_gb', String(m)); | |
| 59 | + const s = p.toString(); | |
| 60 | + return `${canonical}${s ? `?${s}` : ''}#fit`; | |
| 61 | + }; | |
| 62 | + const specRows = Object.keys(a) | |
| 63 | + .filter((k) => !PROSE_KEYS.has(k)) | |
| 64 | + .map((k) => ({ key: k, raw: a[k] })); | |
| 65 | + const items = fit?.items ?? []; | |
| 66 | + const ld = { | |
| 67 | + '@context': 'https://schema.org', | |
| 68 | + '@type': 'Product', | |
| 69 | + name: d.name, | |
| 70 | + brand: typeof a.manufacturer === 'string' ? { '@type': 'Brand', name: a.manufacturer } : undefined, | |
| 71 | + category: typeof a.kind === 'string' ? HW_KIND_LABELS[a.kind] ?? a.kind : 'Hardware', | |
| 72 | + description: d.description ?? undefined, | |
| 73 | + url: `${SITE_URL}${canonical}`, | |
| 74 | + additionalProperty: [ | |
| 75 | + options.length ? { '@type': 'PropertyValue', name: 'Memory', value: fmtMemory(a.memory_gb) } : null, | |
| 76 | + num(a.memory_bandwidth_gbs) !== null ? { '@type': 'PropertyValue', name: 'Memory bandwidth', value: `${fmtInt(a.memory_bandwidth_gbs)} GB/s` } : null, | |
| 77 | + ].filter(Boolean), | |
| 78 | + }; | |
| 79 | + const chip = (on: boolean) => `inline-flex h-9 items-center border px-2.5 text-xs font-medium ${on ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink'}`; | |
| 80 | + | |
| 81 | + return ( | |
| 82 | + <Container wide> | |
| 83 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 84 | + <ViewBeacon path={canonical} /> | |
| 85 | + <header className="pb-5 pt-7 md:pt-10"> | |
| 86 | + <div className="flex flex-wrap items-center gap-2 text-sm text-ink-3"> | |
| 87 | + <Link href={routes.hardware()} className="hover:text-ink">Hardware</Link> <span aria-hidden>/</span> | |
| 88 | + <EntityBadge type={d.entity_type} /> | |
| 89 | + {typeof a.kind === 'string' && <Chip>{HW_KIND_LABELS[a.kind] ?? a.kind}</Chip>} | |
| 90 | + <StatusBadge status={d.status} /> | |
| 91 | + </div> | |
| 92 | + <div className="mt-2 flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> | |
| 93 | + <div className="min-w-0"> | |
| 94 | + <h1 className="display text-[28px] md:text-[40px]">{d.name}</h1> | |
| 95 | + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2"> | |
| 96 | + {typeof a.manufacturer === 'string' && <span>{a.manufacturer}</span>} | |
| 97 | + {d.organization && ( | |
| 98 | + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="hover:text-accent"> | |
| 99 | + {d.organization.name} | |
| 100 | + </Link> | |
| 101 | + )} | |
| 102 | + {typeof a.spec_url === 'string' && ( | |
| 103 | + <a href={a.spec_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:text-accent"> | |
| 104 | + spec sheet <ExternalLink className="size-3" aria-hidden /> | |
| 105 | + </a> | |
| 106 | + )} | |
| 107 | + <QualityMark q={d.quality?.score} label /> | |
| 108 | + </p> | |
| 109 | + <dl className="tnum mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm"> | |
| 110 | + <div><dt className="inline text-ink-3">Memory </dt><dd className="inline font-medium text-ink">{fmtMemory(a.memory_gb)}</dd></div> | |
| 111 | + <div><dt className="inline text-ink-3">Bandwidth </dt><dd className="inline font-medium text-ink">{num(a.memory_bandwidth_gbs) === null ? '—' : `${fmtInt(a.memory_bandwidth_gbs)} GB/s`}</dd></div> | |
| 112 | + <div><dt className="inline text-ink-3">TDP </dt><dd className="inline font-medium text-ink">{num(a.tdp_watts) === null ? '—' : `${fmtInt(a.tdp_watts)} W`}</dd></div> | |
| 113 | + {Array.isArray(a.runtimes) && a.runtimes.length > 0 && <div><dt className="inline text-ink-3">Runtimes </dt><dd className="inline font-medium text-ink">{(a.runtimes as string[]).join(', ')}</dd></div>} | |
| 114 | + </dl> | |
| 115 | + {d.description && <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>} | |
| 116 | + </div> | |
| 117 | + <div className="flex shrink-0 flex-wrap items-center gap-2"> | |
| 118 | + <CompareButton e={d} /> | |
| 119 | + <WatchButton e={d} /> | |
| 120 | + <Link href={`${routes.runLocally()}?hardware=${encodeURIComponent(d.slug)}`} className="inline-flex h-9 items-center border border-rule-strong px-2.5 text-sm font-medium text-ink hover:border-accent hover:text-accent"> | |
| 121 | + Run locally with this device → | |
| 122 | + </Link> | |
| 123 | + </div> | |
| 124 | + </div> | |
| 125 | + </header> | |
| 126 | + | |
| 127 | + <Section eyebrow="Specifications" title="As published" lede="Every value opens its evidence (source, tier, observed time)." hairline={false}> | |
| 128 | + <KeyValue rows={specRows} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} /> | |
| 129 | + </Section> | |
| 130 | + | |
| 131 | + {/* ------------------------------------------------------------------------------------------------- fit */} | |
| 132 | + <Section id="fit" eyebrow="What can this run?" title={<span className="inline-flex flex-wrap items-center gap-2">Models estimated to fit <Estimated /></span>} lede="Estimate from each model's parameter count, the chosen quantization and context — never a measurement." action={{ href: `${routes.runLocally()}?hardware=${encodeURIComponent(d.slug)}&quant=${quant}&context=${context}${memoryUsed ? `&memory_gb=${memoryUsed}` : ''}`, label: 'Open in Run locally' }}> | |
| 133 | + <div className="mb-4 flex flex-wrap items-center gap-x-6 gap-y-3 text-xs"> | |
| 134 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 135 | + <span className="eyebrow mr-1">Quantization</span> | |
| 136 | + {QUANTS.map((q) => ( | |
| 137 | + <Link key={q.value} href={self({ quant: q.value })} className={chip(quant === q.value)} aria-current={quant === q.value ? 'true' : undefined}> | |
| 138 | + {q.value} | |
| 139 | + </Link> | |
| 140 | + ))} | |
| 141 | + </div> | |
| 142 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 143 | + <span className="eyebrow mr-1">Context</span> | |
| 144 | + {CONTEXTS.map((c) => ( | |
| 145 | + <Link key={c} href={self({ context: c })} className={chip(context === c)} aria-current={context === c ? 'true' : undefined}> | |
| 146 | + {fmtTokens(c)} | |
| 147 | + </Link> | |
| 148 | + ))} | |
| 149 | + </div> | |
| 150 | + {options.length > 1 && ( | |
| 151 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 152 | + <span className="eyebrow mr-1">Configuration</span> | |
| 153 | + {options.map((m) => ( | |
| 154 | + <Link key={m} href={self({ memory_gb: m })} className={chip(memoryUsed === m)} aria-current={memoryUsed === m ? 'true' : undefined}> | |
| 155 | + {m} GB | |
| 156 | + </Link> | |
| 157 | + ))} | |
| 158 | + </div> | |
| 159 | + )} | |
| 160 | + </div> | |
| 161 | + {!fit ? ( | |
| 162 | + <Unavailable what="Fit estimate" reason={options.length ? undefined : 'This device has no sourced memory figure — nothing can be estimated.'} /> | |
| 163 | + ) : ( | |
| 164 | + <> | |
| 165 | + <EstimateBanner assumptions={fit.assumptions} note={fit.note ?? null} counts={fit.counts} /> | |
| 166 | + {options.length > 1 && <Note className="mt-2">This device ships in {options.length} memory configurations ({options.join(' / ')} GB). Estimates use {fmtGb(memoryUsed)} — pick another configuration above. Multiple units: use <Link href={`${routes.runLocally()}?hardware=${encodeURIComponent(d.slug)}&gpu_count=2`} className="link">Run locally</Link> (memories are summed; interconnect is not modelled).</Note>} | |
| 167 | + {fit.runtimes?.length > 0 && <p className="mt-2 text-xs text-ink-3">Runtimes recorded for this device: <span className="mono">{fit.runtimes.join(' · ')}</span></p>} | |
| 168 | + {items.length === 0 ? ( | |
| 169 | + <EmptyState title="No model evaluated" className="mt-4">Models without a sourced parameter count are not estimated.</EmptyState> | |
| 170 | + ) : ( | |
| 171 | + <DataTable scroll compact className="mt-4"> | |
| 172 | + <thead> | |
| 173 | + <tr> | |
| 174 | + <Th>Model</Th> | |
| 175 | + <Th num>Params</Th> | |
| 176 | + <Th num>Est. memory</Th> | |
| 177 | + <Th num>Headroom</Th> | |
| 178 | + <Th>Fits</Th> | |
| 179 | + <Th>Breakdown</Th> | |
| 180 | + <Th className="w-24" aria-label="Compare" /> | |
| 181 | + </tr> | |
| 182 | + </thead> | |
| 183 | + <tbody> | |
| 184 | + {items.length === 0 && <EmptyRow cols={7} />} | |
| 185 | + {items.map((r) => { | |
| 186 | + const openness = typeof r.model.attributes?.openness === 'string' ? r.model.attributes.openness : null; | |
| 187 | + const head = num(r.headroom_gb); | |
| 188 | + return ( | |
| 189 | + <tr key={r.model.id}> | |
| 190 | + <Td primary> | |
| 191 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 192 | + <EntityLink e={r.model} /> | |
| 193 | + {openness && <OpennessBadge openness={openness} />} | |
| 194 | + </span> | |
| 195 | + {r.model.organization && <span className="block text-xs text-ink-3">{r.model.organization.name}</span>} | |
| 196 | + </Td> | |
| 197 | + <Td num label="Params" className="tnum">{fmtParams(r.parameter_count ?? r.model.attributes?.parameter_count)}</Td> | |
| 198 | + <Td num label="Est. memory" className="tnum">{fmtGb(r.estimated_memory_gb, 1)} <SourceTag source={r.breakdown?.weights_source ?? 'estimated'} /></Td> | |
| 199 | + <Td num label="Headroom" className={r.fits ? 'tnum text-positive' : 'tnum text-danger'}>{head === null ? '—' : `${head >= 0 ? '+' : '−'}${fmtGb(Math.abs(head), 1)}`}</Td> | |
| 200 | + <Td label="Fits" className={r.fits ? 'font-medium text-positive' : 'text-ink-3'}>{r.fits ? '✓ fits' : '✗ too large'}</Td> | |
| 201 | + <Td label="Breakdown" wide><FitBreakdownList fit={r} /></Td> | |
| 202 | + <Td className="text-right"><CompareButton e={r.model} size="sm" /></Td> | |
| 203 | + </tr> | |
| 204 | + ); | |
| 205 | + })} | |
| 206 | + </tbody> | |
| 207 | + </DataTable> | |
| 208 | + )} | |
| 209 | + {fit.items.length >= 120 && <Note className="mt-2">First 120 models (fitting first). The full list is in <Link href={`${routes.runLocally()}?hardware=${encodeURIComponent(d.slug)}&quant=${quant}&context=${context}`} className="link">Run locally</Link>.</Note>} | |
| 210 | + <Methodology text={fit.note ?? fit.assumptions[0]} /> | |
| 211 | + </> | |
| 212 | + )} | |
| 213 | + </Section> | |
| 214 | + | |
| 215 | + {d.relations?.length ? ( | |
| 216 | + <Section eyebrow="Relations" title="In the graph" action={{ href: routes.graph(d.slug), label: 'Explore graph' }}> | |
| 217 | + <RelationsBlock relations={d.relations} /> | |
| 218 | + </Section> | |
| 219 | + ) : null} | |
| 220 | + <Section eyebrow="Timeline" title="Events" action={{ href: routes.timeline({ entity: d.slug }), label: 'Full timeline' }}> | |
| 221 | + <TimelineList events={d.timeline ?? []} slug={d.slug} /> | |
| 222 | + </Section> | |
| 223 | + <Section eyebrow="Sources" title="Where these facts come from"> | |
| 224 | + <SourcesTable sources={d.sources ?? []} /> | |
| 225 | + </Section> | |
| 226 | + <CompareTrayBar /> | |
| 227 | + </Container> | |
| 228 | + ); | |
| 229 | +} | |
added
apps/web/src/app/hardware/frontier/page.tsx
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import type { Series } from '@/components/charts'; | |
| 4 | +import { HardwareStepChart } from '@/components/intelligence/client-charts'; | |
| 5 | +import { fmtMemory, HW_KIND_LABELS, memoryRange, releaseMs, releaseOf } from '@/components/hardware/hardware-bits'; | |
| 6 | +import { EstimateBanner, Methodology } from '@/components/intelligence/bits'; | |
| 7 | +import { Chip, Estimated } from '@/components/ui/badges'; | |
| 8 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 9 | +import { EntityLink } from '@/components/ui/entity'; | |
| 10 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 11 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 12 | +import { intel, safe } from '@/lib/api'; | |
| 13 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 14 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 15 | +import type { EntitySummary } from '@/lib/types'; | |
| 16 | + | |
| 17 | +export const revalidate = 600; | |
| 18 | +const TITLE = 'Hardware frontier — accelerator memory and bandwidth by release'; | |
| 19 | +const DESC = 'Timeline of accelerators and devices by release date: memory and bandwidth per generation and manufacturer, as published, connected to the models each device is estimated to run at 4-bit.'; | |
| 20 | +export const metadata: Metadata = { title: TITLE, description: DESC, alternates: { canonical: '/hardware/frontier' }, openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}/hardware/frontier`, siteName: SITE_NAME } }; | |
| 21 | + | |
| 22 | +const COLORS = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)', 'var(--series-4)', 'var(--series-5)', 'var(--series-6)']; | |
| 23 | + | |
| 24 | +export default async function HardwareFrontierPage() { | |
| 25 | + const page = await safe(intel.hardware({ limit: 100, sort: 'memory' })); | |
| 26 | + if (!page) { | |
| 27 | + return ( | |
| 28 | + <Container wide> | |
| 29 | + <PageHeader eyebrow="Hardware frontier" title="Accelerators by generation" /> | |
| 30 | + <Unavailable what="Hardware" /> | |
| 31 | + </Container> | |
| 32 | + ); | |
| 33 | + } | |
| 34 | + const all = page.items; | |
| 35 | + const dated = all.map((e) => ({ e, at: releaseMs(releaseOf(e)), mem: memoryRange(e.attributes?.memory_gb), bw: num(e.attributes?.memory_bandwidth_gbs), maker: typeof e.attributes?.manufacturer === 'string' ? (e.attributes.manufacturer as string) : e.organization?.name ?? 'Unknown' })).filter((x) => x.at !== null); | |
| 36 | + const makers = [...new Set(dated.map((x) => x.maker))].sort(); | |
| 37 | + const memSeries: Series[] = makers.map((m, i) => ({ name: m, color: COLORS[i % COLORS.length], points: dated.filter((x) => x.maker === m && x.mem).sort((a, b) => (a.at as number) - (b.at as number)).map((x) => ({ x: new Date(x.at as number), y: (x.mem as [number, number])[1] })) })).filter((s) => s.points.length > 0); | |
| 38 | + const bwSeries: Series[] = makers.map((m, i) => ({ name: m, color: COLORS[i % COLORS.length], points: dated.filter((x) => x.maker === m && x.bw !== null).sort((a, b) => (a.at as number) - (b.at as number)).map((x) => ({ x: new Date(x.at as number), y: x.bw as number })) })).filter((s) => s.points.length > 0); | |
| 39 | + const undated = all.length - dated.length; | |
| 40 | + | |
| 41 | + // connect to fit estimates: models estimated to fit each dated device at 4-bit / 8K (largest configuration) | |
| 42 | + const fits = await Promise.all(dated.slice(0, 40).map(async (x) => ({ slug: x.e.slug, fit: await safe(intel.hardwareSlugFit(x.e.slug, { limit: 1 })) }))); | |
| 43 | + const fitBy = new Map(fits.map((f) => [f.slug, f.fit])); | |
| 44 | + const assumptions = fits.find((f) => f.fit)?.fit?.assumptions ?? []; | |
| 45 | + const byMaker = makers.map((m) => ({ maker: m, rows: dated.filter((x) => x.maker === m).sort((a, b) => (b.at as number) - (a.at as number)) })); | |
| 46 | + const label = (e: EntitySummary) => `${e.name}`; | |
| 47 | + | |
| 48 | + return ( | |
| 49 | + <Container wide> | |
| 50 | + <PageHeader eyebrow={<><Link href={routes.hardware()} className="hover:text-ink">Hardware</Link> <span aria-hidden>/</span> Frontier</>} title="Accelerators by generation" lede="Memory and bandwidth of every dated device the atlas knows, plotted by release and manufacturer (largest configuration when several exist). The right-hand columns connect each device to the models it is estimated to run." aside={<p className="tnum text-sm text-ink-3">{fmtInt(dated.length)} dated devices · {fmtInt(undated)} without a release date</p>} /> | |
| 51 | + | |
| 52 | + <Section eyebrow="Memory per device" title="Device memory by release date" lede="Largest published memory configuration, GB. Steps mark a new generation; hover for the exact device." hairline={false}> | |
| 53 | + {memSeries.reduce((n, s) => n + s.points.length, 0) < 2 ? <EmptyState title="Not enough dated devices to draw" /> : <HardwareStepChart series={memSeries} unit="GB" yLabel="Memory (GB)" />} | |
| 54 | + </Section> | |
| 55 | + <Section eyebrow="Bandwidth per device" title="Memory bandwidth by release date" lede="GB/s as published — the figure that bounds token generation speed for memory-bound inference."> | |
| 56 | + {bwSeries.reduce((n, s) => n + s.points.length, 0) < 2 ? <EmptyState title="Not enough dated devices to draw" /> : <HardwareStepChart series={bwSeries} unit="GB/s" yLabel="Bandwidth (GB/s)" />} | |
| 57 | + </Section> | |
| 58 | + | |
| 59 | + <Section eyebrow="By manufacturer" title="Every dated device, newest first" lede={<span className="inline-flex flex-wrap items-center gap-2">Fit columns are estimates <Estimated /> — models with a known parameter count estimated to fit at 4-bit, 8K context, batch 1, largest configuration.</span>}> | |
| 60 | + <EstimateBanner assumptions={assumptions} className="mb-4" /> | |
| 61 | + {byMaker.map((g) => ( | |
| 62 | + <div key={g.maker} className="mb-8"> | |
| 63 | + <h3 className="mb-2 flex items-baseline gap-2 text-[15px] font-semibold"> | |
| 64 | + {g.maker} <span className="tnum text-xs font-normal text-ink-3">{fmtInt(g.rows.length)} devices</span> | |
| 65 | + </h3> | |
| 66 | + <DataTable compact caption={`${g.maker} devices`}> | |
| 67 | + <thead> | |
| 68 | + <tr> | |
| 69 | + <Th>Device</Th> | |
| 70 | + <Th>Kind</Th> | |
| 71 | + <Th>Release</Th> | |
| 72 | + <Th num>Memory</Th> | |
| 73 | + <Th num>Bandwidth</Th> | |
| 74 | + <Th num>Models that fit @4bit</Th> | |
| 75 | + <Th>Run locally</Th> | |
| 76 | + </tr> | |
| 77 | + </thead> | |
| 78 | + <tbody> | |
| 79 | + {g.rows.length === 0 && <EmptyRow cols={7} />} | |
| 80 | + {g.rows.map(({ e, mem, bw }) => { | |
| 81 | + const f = fitBy.get(e.slug); | |
| 82 | + const rel = releaseOf(e); | |
| 83 | + return ( | |
| 84 | + <tr key={e.id}> | |
| 85 | + <Td primary><EntityLink e={e}>{label(e)}</EntityLink></Td> | |
| 86 | + <Td label="Kind">{typeof e.attributes?.kind === 'string' ? <Chip>{HW_KIND_LABELS[e.attributes.kind as string] ?? (e.attributes.kind as string)}</Chip> : '—'}</Td> | |
| 87 | + <Td label="Release" className="tnum text-ink-2 whitespace-nowrap">{rel ? fmtDate(rel) : '—'}</Td> | |
| 88 | + <Td num label="Memory" className="tnum">{mem ? fmtMemory(e.attributes?.memory_gb) : '—'}</Td> | |
| 89 | + <Td num label="Bandwidth" className="tnum text-ink-2">{bw === null ? '—' : `${fmtInt(bw)} GB/s`}</Td> | |
| 90 | + <Td num label="Fit @4bit" className="tnum"> | |
| 91 | + {f ? ( | |
| 92 | + <> | |
| 93 | + <span className="font-medium text-positive">{fmtInt(f.counts.fits)}</span> <span className="text-ink-3">/ {fmtInt(f.counts.evaluated)}</span> | |
| 94 | + </> | |
| 95 | + ) : ( | |
| 96 | + <span className="text-ink-3">—</span> | |
| 97 | + )} | |
| 98 | + </Td> | |
| 99 | + <Td label="Run locally"> | |
| 100 | + <Link href={`${routes.runLocally()}?hardware=${encodeURIComponent(e.slug)}`} className="link text-xs whitespace-nowrap"> | |
| 101 | + What can it run? → | |
| 102 | + </Link> | |
| 103 | + </Td> | |
| 104 | + </tr> | |
| 105 | + ); | |
| 106 | + })} | |
| 107 | + </tbody> | |
| 108 | + </DataTable> | |
| 109 | + </div> | |
| 110 | + ))} | |
| 111 | + {undated > 0 && ( | |
| 112 | + <Note> | |
| 113 | + {fmtInt(undated)} device{undated === 1 ? '' : 's'} without a sourced release date are not plotted (they remain in the <Link href={routes.hardware()} className="link">listing</Link>). | |
| 114 | + </Note> | |
| 115 | + )} | |
| 116 | + <Methodology text="Release dates and specifications as published by the manufacturer (year- or month-precision dates are plotted mid-period). Fit counts come from GET /hardware/{slug}/fit with default inputs and are estimates." /> | |
| 117 | + </Section> | |
| 118 | + </Container> | |
| 119 | + ); | |
| 120 | +} | |
modified
apps/web/src/app/hardware/page.tsx
+177 −32
@@ -2,44 +2,189 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { CompareButton } from '@/components/compare/compare-button'; |
| 4 | 4 | import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; |
| 5 | −import { GenericListing } from '@/components/listing/generic-listing'; | |
| 6 | −import { api } from '@/lib/api'; | |
| 7 | −import { routes } from '@/lib/site'; | |
| 5 | +import { fmtMemory, HW_KIND_LABELS, interconnect, precisions, releaseOf } from '@/components/hardware/hardware-bits'; | |
| 6 | +import { BTN_GHOST, CTRL, Field, SortTh } from '@/components/intelligence/bits'; | |
| 7 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 8 | +import { Chip } from '@/components/ui/badges'; | |
| 9 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 10 | +import { EntityLink } from '@/components/ui/entity'; | |
| 11 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 12 | +import { Note, PageHeader } from '@/components/ui/section'; | |
| 13 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 14 | +import { intel, safe } from '@/lib/api'; | |
| 15 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 16 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 8 | 17 | |
| 9 | −export const metadata: Metadata = { title: 'AI hardware — GPUs, accelerators, memory and bandwidth', description: 'Accelerators and devices for training and inference: memory, bandwidth, TDP, runtimes — and which models are estimated to fit.', alternates: { canonical: '/hardware' } }; | |
| 10 | 18 | export const revalidate = 600; |
| 19 | +type SP = Record<string, string | undefined>; | |
| 20 | +const LIMIT = 50; | |
| 21 | +const KEYS = ['q', 'kind', 'manufacturer', 'min_memory', 'sort', 'offset'] as const; | |
| 22 | +const SORTS = [ | |
| 23 | + { value: 'memory', label: 'Memory' }, | |
| 24 | + { value: 'name', label: 'Name' }, | |
| 25 | + { value: 'updated', label: 'Recently updated' }, | |
| 26 | +]; | |
| 11 | 27 | |
| 12 | −export default async function HardwarePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 28 | +const TITLE = 'AI hardware — GPUs, accelerators, SoCs and systems: memory, bandwidth, TDP'; | |
| 29 | +const DESC = 'Accelerators and devices for training and inference with the memory, bandwidth, TDP, precision support and interconnect figures published by their manufacturers — and, for each, which models are estimated to fit.'; | |
| 30 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 13 | 31 | const sp = await searchParams; |
| 14 | − return ( | |
| 15 | − <GenericListing | |
| 16 | − type="hardware" | |
| 17 | − basePath="/hardware" | |
| 18 | − eyebrow="Hardware" | |
| 19 | − title="Hardware" | |
| 20 | − lede="GPUs, accelerators and consumer devices with the memory and bandwidth figures published by their manufacturers." | |
| 21 | − searchParams={sp} | |
| 22 | − fetch={(q) => api.hardware(q)} | |
| 23 | − sorts={[ | |
| 24 | − { value: 'memory', label: 'Memory' }, | |
| 25 | − { value: 'name', label: 'Name' }, | |
| 26 | − { value: 'updated', label: 'Recently updated' }, | |
| 27 | − ]} | |
| 28 | − extraFields={[ | |
| 29 | − { kind: 'text', name: 'kind', label: 'Kind', value: sp.kind, placeholder: 'gpu, accelerator, soc…' }, | |
| 30 | − { kind: 'text', name: 'manufacturer', label: 'Manufacturer', value: sp.manufacturer, placeholder: 'NVIDIA, Apple…' }, | |
| 31 | − ]} | |
| 32 | − rowTrailing={(e) => <CompareButton e={e} size="sm" />} | |
| 33 | − headerAside={ | |
| 34 | − <Link href={routes.hardwareFit()} className="inline-flex h-10 items-center border border-rule-strong px-3 text-sm font-medium text-ink hover:border-accent hover:text-accent"> | |
| 35 | − What fits my machine? → | |
| 32 | + const filtered = ['q', 'kind', 'manufacturer', 'min_memory', 'offset'].some((k) => sp[k]); | |
| 33 | + return { title: TITLE, description: DESC, alternates: { canonical: routes.hardware() }, openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.hardware()}`, siteName: SITE_NAME }, robots: filtered ? { index: false, follow: true } : undefined }; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export default async function HardwarePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 37 | + const sp = await searchParams; | |
| 38 | + const cur: Record<string, string | undefined> = {}; | |
| 39 | + for (const k of KEYS) if (sp[k]) cur[k] = sp[k]; | |
| 40 | + const offset = Math.max(0, Number(cur.offset) || 0); | |
| 41 | + const sort = SORTS.some((s) => s.value === cur.sort) ? (cur.sort as string) : 'memory'; | |
| 42 | + const page = await safe(intel.hardware({ q: cur.q, kind: cur.kind, manufacturer: cur.manufacturer, min_memory: num(cur.min_memory) ?? undefined, sort, limit: LIMIT, offset })); | |
| 43 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/hardware', cur, patch); | |
| 44 | + const kinds = page?.facets?.kinds ?? []; | |
| 45 | + const manufacturers = page?.facets?.manufacturers ?? []; | |
| 46 | + const filterCount = ['q', 'kind', 'manufacturer', 'min_memory'].filter((k) => cur[k]).length; | |
| 47 | + const items = page?.items ?? []; | |
| 48 | + const withTdp = items.filter((e) => num(e.attributes?.tdp_watts) !== null).length; | |
| 49 | + const withPrec = items.filter((e) => precisions(e.attributes ?? {}).length).length; | |
| 50 | + | |
| 51 | + const filters = ( | |
| 52 | + <form action="/hardware" method="get" className="space-y-3" data-hardware-filters> | |
| 53 | + <Field label="Name"> | |
| 54 | + <input name="q" defaultValue={cur.q ?? ''} placeholder="H100, M4 Max…" className={CTRL} /> | |
| 55 | + </Field> | |
| 56 | + <Field label="Kind"> | |
| 57 | + <select name="kind" defaultValue={cur.kind ?? ''} className={CTRL}> | |
| 58 | + <option value="">Any kind</option> | |
| 59 | + {kinds.map((k) => ( | |
| 60 | + <option key={k.value} value={k.value}> | |
| 61 | + {HW_KIND_LABELS[k.value] ?? k.value} ({fmtInt(k.count)}) | |
| 62 | + </option> | |
| 63 | + ))} | |
| 64 | + {cur.kind && !kinds.some((k) => k.value === cur.kind) && <option value={cur.kind}>{HW_KIND_LABELS[cur.kind] ?? cur.kind}</option>} | |
| 65 | + </select> | |
| 66 | + </Field> | |
| 67 | + <Field label="Manufacturer"> | |
| 68 | + <select name="manufacturer" defaultValue={cur.manufacturer ?? ''} className={CTRL}> | |
| 69 | + <option value="">Any manufacturer</option> | |
| 70 | + {manufacturers.map((m) => ( | |
| 71 | + <option key={m.value} value={m.value}> | |
| 72 | + {m.value} ({fmtInt(m.count)}) | |
| 73 | + </option> | |
| 74 | + ))} | |
| 75 | + {cur.manufacturer && !manufacturers.some((m) => m.value === cur.manufacturer) && <option value={cur.manufacturer}>{cur.manufacturer}</option>} | |
| 76 | + </select> | |
| 77 | + </Field> | |
| 78 | + <Field label="Memory ≥ (GB)"> | |
| 79 | + <input name="min_memory" inputMode="numeric" defaultValue={cur.min_memory ?? ''} placeholder="e.g. 64" className={CTRL} /> | |
| 80 | + </Field> | |
| 81 | + <Field label="Sort"> | |
| 82 | + <select name="sort" defaultValue={sort} className={CTRL}> | |
| 83 | + {SORTS.map((s) => ( | |
| 84 | + <option key={s.value} value={s.value}> | |
| 85 | + {s.label} | |
| 86 | + </option> | |
| 87 | + ))} | |
| 88 | + </select> | |
| 89 | + </Field> | |
| 90 | + <div className="flex gap-2"> | |
| 91 | + <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink lg:h-10 px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 92 | + Apply | |
| 93 | + </button> | |
| 94 | + <Link href="/hardware" className={BTN_GHOST}> | |
| 95 | + Reset | |
| 36 | 96 | </Link> |
| 37 | − } | |
| 38 | − > | |
| 39 | − <p className="mb-6 text-sm text-ink-2"> | |
| 40 | − Each device page lists the models estimated to fit its memory per quantization — labelled as estimates, method on <Link href="/methodology#estimates" className="link">/methodology</Link>. Use <Link href={routes.hardwareFit()} className="link">the fit tool</Link> for any memory size. | |
| 97 | + </div> | |
| 98 | + </form> | |
| 99 | + ); | |
| 100 | + const inspector = ( | |
| 101 | + <div className="space-y-3 text-xs leading-relaxed text-ink-2"> | |
| 102 | + <p>Figures are the manufacturer's published specifications (spec sheets, tier 2 when read from a reseller). A dash means no source has stated the value — TDP, precision support and interconnect are sparse in the current corpus ({fmtInt(withTdp)} / {fmtInt(withPrec)} of the {fmtInt(items.length)} rows shown carry TDP / precision).</p> | |
| 103 | + <p>Memory listed as a range = several configurations (Apple silicon tiers); fit estimates use the largest unless you pick one.</p> | |
| 104 | + <p> | |
| 105 | + <Link href={routes.runLocally()} className="link">Run locally</Link> · <Link href="/hardware/frontier" className="link">Hardware frontier</Link> · <Link href="/methodology#estimates" className="link">estimate method</Link> | |
| 41 | 106 | </p> |
| 107 | + </div> | |
| 108 | + ); | |
| 109 | + | |
| 110 | + return ( | |
| 111 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Reading" storageKey="aia-inspector-hardware" filterCount={filterCount}> | |
| 112 | + <PageHeader eyebrow="Hardware" title="Hardware" lede="GPUs, accelerators, SoCs and systems with the memory and bandwidth figures their manufacturers publish. Every device links to what it is estimated to run." className="pt-4 md:pt-6" aside={ | |
| 113 | + <div className="flex flex-col items-start gap-2 md:items-end"> | |
| 114 | + {page && <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} devices</p>} | |
| 115 | + <span className="flex flex-wrap gap-2"> | |
| 116 | + <Link href="/hardware/frontier" className="inline-flex h-10 items-center border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 117 | + Hardware frontier → | |
| 118 | + </Link> | |
| 119 | + <Link href={routes.runLocally()} className="inline-flex h-10 items-center border border-rule-strong px-3 text-sm font-medium text-ink hover:border-accent hover:text-accent"> | |
| 120 | + What fits my machine? → | |
| 121 | + </Link> | |
| 122 | + </span> | |
| 123 | + </div> | |
| 124 | + } /> | |
| 125 | + <div className="pb-16"> | |
| 126 | + {!page ? ( | |
| 127 | + <Unavailable what="Hardware" /> | |
| 128 | + ) : items.length === 0 && !offset ? ( | |
| 129 | + <EmptyState title={filterCount ? 'No hardware matches these filters' : 'No hardware recorded yet'}>{filterCount ? 'Remove a filter.' : 'Connectors: manufacturer spec pages.'}</EmptyState> | |
| 130 | + ) : ( | |
| 131 | + <> | |
| 132 | + <DataTable scroll compact> | |
| 133 | + <thead> | |
| 134 | + <tr> | |
| 135 | + <SortTh active={sort === 'name'} href={href({ sort: 'name', offset: undefined })}>Hardware</SortTh> | |
| 136 | + <Th>Kind</Th> | |
| 137 | + <SortTh active={sort === 'memory'} href={href({ sort: undefined, offset: undefined })} num dir="desc">Memory</SortTh> | |
| 138 | + <Th num>Bandwidth</Th> | |
| 139 | + <Th num>TDP</Th> | |
| 140 | + <Th>Precision</Th> | |
| 141 | + <Th>Interconnect</Th> | |
| 142 | + <Th>Release</Th> | |
| 143 | + <Th>Fit</Th> | |
| 144 | + <Th className="w-24" aria-label="Compare" /> | |
| 145 | + </tr> | |
| 146 | + </thead> | |
| 147 | + <tbody> | |
| 148 | + {items.length === 0 && <EmptyRow cols={10} />} | |
| 149 | + {items.map((e) => { | |
| 150 | + const a = e.attributes ?? {}; | |
| 151 | + const prec = precisions(a); | |
| 152 | + const ic = interconnect(a); | |
| 153 | + const rel = releaseOf(e); | |
| 154 | + return ( | |
| 155 | + <tr key={e.id} data-hardware-row> | |
| 156 | + <Td primary> | |
| 157 | + <EntityLink e={e} /> | |
| 158 | + <span className="block text-xs text-ink-3">{typeof a.manufacturer === 'string' ? a.manufacturer : e.organization?.name ?? '—'}</span> | |
| 159 | + </Td> | |
| 160 | + <Td label="Kind">{typeof a.kind === 'string' ? <Chip>{HW_KIND_LABELS[a.kind] ?? a.kind}</Chip> : <span className="text-ink-3">—</span>}</Td> | |
| 161 | + <Td num label="Memory" className="tnum font-medium">{fmtMemory(a.memory_gb)}{typeof a.memory_type === 'string' && <span className="block text-[10px] font-normal text-ink-3">{a.memory_type}</span>}</Td> | |
| 162 | + <Td num label="Bandwidth" className="tnum text-ink-2">{num(a.memory_bandwidth_gbs) === null ? <span className="text-ink-3">—</span> : `${fmtInt(a.memory_bandwidth_gbs)} GB/s`}</Td> | |
| 163 | + <Td num label="TDP" className="tnum text-ink-2">{num(a.tdp_watts) === null ? <span className="text-ink-3">—</span> : `${fmtInt(a.tdp_watts)} W`}</Td> | |
| 164 | + <Td label="Precision" className="mono text-[11px] text-ink-2">{prec.length ? prec.join(' · ') : <span className="font-sans text-xs text-ink-3">—</span>}</Td> | |
| 165 | + <Td label="Interconnect" className="text-xs text-ink-2">{ic ?? <span className="text-ink-3">—</span>}</Td> | |
| 166 | + <Td label="Release" className="tnum text-ink-2 whitespace-nowrap">{rel ? fmtDate(rel) : <span className="text-ink-3">—</span>}</Td> | |
| 167 | + <Td label="Fit"> | |
| 168 | + <Link href={`${routes.runLocally()}?hardware=${encodeURIComponent(e.slug)}`} className="link whitespace-nowrap text-xs"> | |
| 169 | + What can it run? → | |
| 170 | + </Link> | |
| 171 | + </Td> | |
| 172 | + <Td className="text-right"> | |
| 173 | + <CompareButton e={e} size="sm" /> | |
| 174 | + </Td> | |
| 175 | + </tr> | |
| 176 | + ); | |
| 177 | + })} | |
| 178 | + </tbody> | |
| 179 | + </DataTable> | |
| 180 | + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 181 | + <Note className="mt-3"> | |
| 182 | + Each device page lists the models estimated to fit its memory per quantization — labelled as estimates, method on <Link href="/methodology#estimates" className="link">/methodology</Link>. A dash = not stated by any source (never guessed). | |
| 183 | + </Note> | |
| 184 | + </> | |
| 185 | + )} | |
| 186 | + </div> | |
| 42 | 187 | <CompareTrayBar /> |
| 43 | − </GenericListing> | |
| 188 | + </TerminalLayout> | |
| 44 | 189 | ); |
| 45 | 190 | } |
added
apps/web/src/app/open/opengraph-image.tsx
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { intel, safe } from '@/lib/api'; | |
| 4 | +import { fmtInt } from '@/lib/format'; | |
| 5 | +import { SITE_NAME } from '@/lib/site'; | |
| 6 | + | |
| 7 | +export const runtime = 'nodejs'; | |
| 8 | +export const alt = `Open Model Frontier on ${SITE_NAME}`; | |
| 9 | +export const size = { width: 1200, height: 630 }; | |
| 10 | +export const contentType = 'image/png'; | |
| 11 | + | |
| 12 | +export default async function OpenOgImage() { | |
| 13 | + const r = await safe(intel.open({ limit: 1 })); | |
| 14 | + const c = r?.summary?.by_category ?? {}; | |
| 15 | + const counters: [string, string][] = r ? [['Downloadable models', fmtInt(r.total)], ['Open weights', fmtInt(c['open-weights'] ?? 0)], ['Restricted weights', fmtInt(c['restricted-weights'] ?? 0)], ['New in 30 days', fmtInt(r.summary?.new_30d)]] : []; | |
| 16 | + return new ImageResponse(<Wallpaper eyebrow="Open model frontier" title="Open models" subtitle="Downloadable weights by licence permissions, parameters, context, release, benchmark ranks and estimated local fit — measurable dimensions, no score." counters={counters} footer="www.ai-atlas.co/open" markPx={240} />, { ...size }); | |
| 17 | +} | |
added
apps/web/src/app/open/page.tsx
+361 −0
@@ -0,0 +1,361 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 4 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 5 | +import { BTN_GHOST, CTRL, Field, FitCell, LicenceLegend, LicencePerms, Methodology, RankChips, SortTh } from '@/components/intelligence/bits'; | |
| 6 | +import { DataStrip, type StripItem, TerminalLayout } from '@/components/layout/terminal'; | |
| 7 | +import { Chip, Estimated, OpennessBadge } from '@/components/ui/badges'; | |
| 8 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 9 | +import { EntityLink } from '@/components/ui/entity'; | |
| 10 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 11 | +import { Note, PageHeader, Section } from '@/components/ui/section'; | |
| 12 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 13 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 14 | +import { intel, safe } from '@/lib/api'; | |
| 15 | +import { fmtDate, fmtInt, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format'; | |
| 16 | +import { OPENNESS_LABELS, routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 17 | +import type { OpenItem } from '@/lib/types'; | |
| 18 | + | |
| 19 | +export const revalidate = 300; | |
| 20 | +type SP = Record<string, string | undefined>; | |
| 21 | +const LIMIT = 50; | |
| 22 | +const SORTS = ['rank', 'release', 'params', 'context', 'downloads', 'name'] as const; | |
| 23 | +type Sort = (typeof SORTS)[number]; | |
| 24 | +const SORT_LABELS: Record<Sort, string> = { rank: 'Best benchmark rank', release: 'Release date', params: 'Parameters', context: 'Context window', downloads: 'Downloads', name: 'Name' }; | |
| 25 | +const KEYS = ['sort', 'license', 'min_params', 'max_params', 'min_context', 'modality', 'days', 'openness', 'offset'] as const; | |
| 26 | +const CATS = ['open-source', 'open-weights', 'restricted-weights'] as const; | |
| 27 | +const CAT_COLOR: Record<string, string> = { 'open-source': 'var(--positive)', 'open-weights': 'var(--series-1)', 'restricted-weights': 'var(--warning)', proprietary: 'var(--ink-3)' }; | |
| 28 | + | |
| 29 | +function parseScale(v: string | undefined): number | undefined { | |
| 30 | + if (!v) return undefined; | |
| 31 | + const m = /^\s*([\d.]+)\s*([kmbt])?\s*$/i.exec(v); | |
| 32 | + if (!m) return undefined; | |
| 33 | + const mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[(m[2] ?? '').toLowerCase() as 'k' | 'm' | 'b' | 't'] ?? 1; | |
| 34 | + const n = Number(m[1]); | |
| 35 | + return Number.isFinite(n) ? Math.round(n * mult) : undefined; | |
| 36 | +} | |
| 37 | +function pick(sp: SP) { | |
| 38 | + const cur: Record<string, string | undefined> = {}; | |
| 39 | + for (const k of KEYS) if (sp[k]) cur[k] = sp[k]; | |
| 40 | + const sort: Sort = (SORTS as readonly string[]).includes(cur.sort ?? '') ? (cur.sort as Sort) : 'rank'; | |
| 41 | + return { cur, sort, offset: Math.max(0, Number(cur.offset) || 0) }; | |
| 42 | +} | |
| 43 | + | |
| 44 | +const TITLE = 'Open Model Frontier — downloadable AI models by licence, size, context and rank'; | |
| 45 | +const DESC = 'Every canonical model whose weights can be downloaded — open source, open weights, restricted weights — with its licence permissions (commercial use, redistribution, derivatives, hosting), parameters, context, release date, best benchmark ranks, providers and an estimated fit on 64 GB / 128 GB machines. Measurable openness dimensions, no ideological score.'; | |
| 46 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 47 | + const { cur } = pick(await searchParams); | |
| 48 | + const filtered = Object.keys(cur).some((k) => k !== 'sort'); | |
| 49 | + return { title: TITLE, description: DESC, alternates: { canonical: routes.open() }, openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.open()}`, type: 'website', siteName: SITE_NAME }, twitter: { card: 'summary_large_image', title: TITLE, description: DESC }, robots: filtered ? { index: false, follow: true } : undefined }; | |
| 50 | +} | |
| 51 | + | |
| 52 | +export default async function OpenPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 53 | + const sp = await searchParams; | |
| 54 | + const { cur, sort, offset } = pick(sp); | |
| 55 | + const [res, meth] = await Promise.all([safe(intel.open({ sort, license: cur.license, min_params: parseScale(cur.min_params), max_params: parseScale(cur.max_params), min_context: parseScale(cur.min_context), modality: cur.modality, days: cur.days, openness: cur.openness, limit: LIMIT, offset })), safe(intel.methodology())]); | |
| 56 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/open', cur, patch); | |
| 57 | + const defs = meth?.openness?.definitions ?? {}; | |
| 58 | + const labels: Record<string, string> = { ...OPENNESS_LABELS, 'restricted-weights': 'Restricted weights', ...(meth?.openness?.labels ?? {}) }; | |
| 59 | + const byCat = res?.summary?.by_category ?? {}; | |
| 60 | + const catTotal = Object.values(byCat).reduce<number>((n, v) => n + (num(v) ?? 0), 0); | |
| 61 | + const items: OpenItem[] = res?.items ?? []; | |
| 62 | + const filterCount = Object.keys(cur).filter((k) => k !== 'sort' && k !== 'offset').length; | |
| 63 | + const licences = res?.summary?.by_license_top ?? []; | |
| 64 | + | |
| 65 | + const strip: StripItem[] = [ | |
| 66 | + ...CATS.map((c) => ({ label: labels[c] ?? c, value: fmtInt(byCat[c] ?? 0), definition: defs[c], href: href({ openness: c, offset: undefined }) })), | |
| 67 | + { label: 'Downloadable total', value: fmtInt(res?.total), definition: res?.note ?? 'Universe = canonical models whose weights can be downloaded.' }, | |
| 68 | + { label: 'New in 30 days', value: fmtInt(res?.summary?.new_30d), definition: 'Downloadable models with a release date inside the last 30 days (release_date claim; models without one are not counted).', href: href({ days: 30, offset: undefined }) }, | |
| 69 | + ]; | |
| 70 | + | |
| 71 | + // licence permission matrix over the rows on this page (measurable: each licence's stated permissions) | |
| 72 | + const PERMS: { key: 'commercial_use' | 'redistribution' | 'derivatives' | 'hosting'; label: string }[] = [ | |
| 73 | + { key: 'commercial_use', label: 'Commercial use' }, | |
| 74 | + { key: 'redistribution', label: 'Redistribution' }, | |
| 75 | + { key: 'derivatives', label: 'Derivatives' }, | |
| 76 | + { key: 'hosting', label: 'Hosting' }, | |
| 77 | + ]; | |
| 78 | + const matrix = CATS.map((c) => { | |
| 79 | + const rows = items.filter((it) => it.dimensions?.openness === c); | |
| 80 | + return { | |
| 81 | + cat: c, | |
| 82 | + n: rows.length, | |
| 83 | + cells: PERMS.map((p) => { | |
| 84 | + let yes = 0; | |
| 85 | + let no = 0; | |
| 86 | + let unk = 0; | |
| 87 | + for (const it of rows) { | |
| 88 | + const l = it.licence; | |
| 89 | + if (!l || l.key === null) { | |
| 90 | + unk++; | |
| 91 | + continue; | |
| 92 | + } | |
| 93 | + const v = p.key === 'hosting' ? (l.hosting_restrictions === null || l.hosting_restrictions === undefined ? null : !l.hosting_restrictions) : l[p.key]; | |
| 94 | + if (v === true) yes++; | |
| 95 | + else if (v === false) no++; | |
| 96 | + else unk++; | |
| 97 | + } | |
| 98 | + return { yes, no, unk }; | |
| 99 | + }), | |
| 100 | + }; | |
| 101 | + }); | |
| 102 | + | |
| 103 | + const filters = ( | |
| 104 | + <form action="/open" method="get" className="space-y-3" data-open-filters> | |
| 105 | + <Field label="Openness category"> | |
| 106 | + <select name="openness" defaultValue={cur.openness ?? ''} className={CTRL}> | |
| 107 | + <option value="">All downloadable</option> | |
| 108 | + {CATS.map((c) => ( | |
| 109 | + <option key={c} value={c}> | |
| 110 | + {labels[c] ?? c} | |
| 111 | + </option> | |
| 112 | + ))} | |
| 113 | + </select> | |
| 114 | + </Field> | |
| 115 | + <Field label="Licence"> | |
| 116 | + <select name="license" defaultValue={cur.license ?? ''} className={CTRL}> | |
| 117 | + <option value="">Any licence</option> | |
| 118 | + {licences.map((l) => ( | |
| 119 | + <option key={l.key} value={l.key}> | |
| 120 | + {l.label} ({fmtInt(l.models)}) | |
| 121 | + </option> | |
| 122 | + ))} | |
| 123 | + {cur.license && !licences.some((l) => l.key === cur.license) && <option value={cur.license}>{cur.license}</option>} | |
| 124 | + </select> | |
| 125 | + </Field> | |
| 126 | + <div className="grid grid-cols-2 gap-2"> | |
| 127 | + <Field label="Params ≥"> | |
| 128 | + <input name="min_params" defaultValue={cur.min_params ?? ''} placeholder="7B" className={CTRL} /> | |
| 129 | + </Field> | |
| 130 | + <Field label="Params ≤"> | |
| 131 | + <input name="max_params" defaultValue={cur.max_params ?? ''} placeholder="70B" className={CTRL} /> | |
| 132 | + </Field> | |
| 133 | + </div> | |
| 134 | + <Field label="Context ≥"> | |
| 135 | + <input name="min_context" defaultValue={cur.min_context ?? ''} placeholder="128k" className={CTRL} /> | |
| 136 | + </Field> | |
| 137 | + <Field label="Modality"> | |
| 138 | + <select name="modality" defaultValue={cur.modality ?? ''} className={CTRL}> | |
| 139 | + <option value="">Any</option> | |
| 140 | + {['text', 'image', 'audio', 'video', 'embedding'].map((m) => ( | |
| 141 | + <option key={m} value={m}> | |
| 142 | + {m} | |
| 143 | + </option> | |
| 144 | + ))} | |
| 145 | + </select> | |
| 146 | + </Field> | |
| 147 | + <Field label="Released within"> | |
| 148 | + <select name="days" defaultValue={cur.days ?? ''} className={CTRL}> | |
| 149 | + <option value="">Any date</option> | |
| 150 | + <option value="30">30 days</option> | |
| 151 | + <option value="90">90 days</option> | |
| 152 | + <option value="365">1 year</option> | |
| 153 | + </select> | |
| 154 | + </Field> | |
| 155 | + <Field label="Sort"> | |
| 156 | + <select name="sort" defaultValue={sort} className={CTRL}> | |
| 157 | + {SORTS.map((s) => ( | |
| 158 | + <option key={s} value={s}> | |
| 159 | + {SORT_LABELS[s]} | |
| 160 | + </option> | |
| 161 | + ))} | |
| 162 | + </select> | |
| 163 | + </Field> | |
| 164 | + <div className="flex gap-2"> | |
| 165 | + <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink lg:h-10 px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 166 | + Apply | |
| 167 | + </button> | |
| 168 | + <Link href="/open" className={BTN_GHOST}> | |
| 169 | + Reset | |
| 170 | + </Link> | |
| 171 | + </div> | |
| 172 | + </form> | |
| 173 | + ); | |
| 174 | + | |
| 175 | + const inspector = ( | |
| 176 | + <div className="space-y-4 text-xs leading-relaxed text-ink-2"> | |
| 177 | + <div> | |
| 178 | + <p className="eyebrow mb-1">Openness categories</p> | |
| 179 | + <dl className="space-y-1.5"> | |
| 180 | + {(meth?.openness?.categories ?? [...CATS, 'proprietary']).map((c) => ( | |
| 181 | + <div key={c}> | |
| 182 | + <dt className="font-medium text-ink">{labels[c] ?? c}</dt> | |
| 183 | + <dd>{defs[c] ?? '—'}</dd> | |
| 184 | + </div> | |
| 185 | + ))} | |
| 186 | + </dl> | |
| 187 | + {meth?.openness?.note && <p className="mt-1.5 text-ink-3">{meth.openness.note}</p>} | |
| 188 | + </div> | |
| 189 | + <div> | |
| 190 | + <p className="eyebrow mb-1">Measured dimensions</p> | |
| 191 | + <ul className="mono flex flex-wrap gap-1"> | |
| 192 | + {(meth?.openness?.dimensions ?? []).map((d) => ( | |
| 193 | + <li key={d} className="rounded-[3px] bg-surface-2 px-1.5 py-[1px] text-[10px]"> | |
| 194 | + {d} | |
| 195 | + </li> | |
| 196 | + ))} | |
| 197 | + </ul> | |
| 198 | + <p className="mt-1 text-ink-3">Each model page states these booleans with their source; the category is derived from them and the licence ontology.</p> | |
| 199 | + </div> | |
| 200 | + <p> | |
| 201 | + <Link href={routes.licenses()} className="link">Licence ontology</Link> · <Link href="/methodology" className="link">/methodology</Link> | |
| 202 | + </p> | |
| 203 | + </div> | |
| 204 | + ); | |
| 205 | + | |
| 206 | + return ( | |
| 207 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Definitions" storageKey="aia-inspector-open" filterCount={filterCount}> | |
| 208 | + <PageHeader eyebrow="Open model frontier" title="Open models" lede="Every canonical model whose weights can be downloaded, described by measurable properties — licence permissions, parameters, context, release, benchmark ranks, providers, estimated local fit. Openness is a set of observed dimensions here, never a score." className="pt-4 md:pt-6" aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(res.total)} downloadable models</p> : undefined} /> | |
| 209 | + {!res ? ( | |
| 210 | + <Unavailable what="Open models" /> | |
| 211 | + ) : ( | |
| 212 | + <> | |
| 213 | + <DataStrip items={strip} dense /> | |
| 214 | + | |
| 215 | + {/* ------------------------------------------------------------------------------- openness explorer */} | |
| 216 | + <Section eyebrow="Openness explorer" title="Categories and licence permissions" lede="Left: the downloadable universe by openness category. Right: what the licences on this page allow, counted per category — allowed · restricted · unknown." hairline={false}> | |
| 217 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]"> | |
| 218 | + <div> | |
| 219 | + <div className="flex h-5 w-full overflow-hidden rounded-[3px] bg-surface-2" role="img" aria-label="Downloadable models by openness category"> | |
| 220 | + {CATS.map((c) => { | |
| 221 | + const n = num(byCat[c]) ?? 0; | |
| 222 | + return n > 0 ? <span key={c} style={{ width: `${(100 * n) / Math.max(1, catTotal)}%`, background: CAT_COLOR[c] }} title={`${labels[c] ?? c}: ${fmtInt(n)}`} /> : null; | |
| 223 | + })} | |
| 224 | + </div> | |
| 225 | + <ul className="mt-2 space-y-1 text-sm"> | |
| 226 | + {CATS.map((c) => ( | |
| 227 | + <li key={c} className="flex items-baseline gap-2"> | |
| 228 | + <span className="inline-block size-2.5 shrink-0 rounded-[2px]" style={{ background: CAT_COLOR[c] }} aria-hidden /> | |
| 229 | + <Link href={href({ openness: c, offset: undefined })} className="text-ink hover:text-accent"> | |
| 230 | + {labels[c] ?? c} | |
| 231 | + </Link> | |
| 232 | + <span className="tnum ml-auto text-ink-2"> | |
| 233 | + {fmtInt(byCat[c] ?? 0)} <span className="text-ink-3">{catTotal ? `${Math.round((100 * (num(byCat[c]) ?? 0)) / catTotal)}%` : ''}</span> | |
| 234 | + </span> | |
| 235 | + </li> | |
| 236 | + ))} | |
| 237 | + </ul> | |
| 238 | + <p className="mt-3 text-xs text-ink-3">Top licences: {licences.slice(0, 6).map((l) => `${l.label ?? l.key} (${fmtInt(l.models)})`).join(' · ') || '—'}</p> | |
| 239 | + </div> | |
| 240 | + <div className="table-scroll"> | |
| 241 | + <table className="data-table compact" aria-label="Licence permissions by openness category (models on this page)"> | |
| 242 | + <thead> | |
| 243 | + <tr> | |
| 244 | + <th scope="col">Category · this page</th> | |
| 245 | + {PERMS.map((p) => ( | |
| 246 | + <th key={p.key} scope="col" className="num"> | |
| 247 | + {p.label} | |
| 248 | + </th> | |
| 249 | + ))} | |
| 250 | + </tr> | |
| 251 | + </thead> | |
| 252 | + <tbody> | |
| 253 | + {matrix.map((r) => ( | |
| 254 | + <tr key={r.cat}> | |
| 255 | + <th scope="row" className="text-left font-normal"> | |
| 256 | + <OpennessBadge openness={r.cat} /> <span className="tnum text-xs text-ink-3">n={r.n}</span> | |
| 257 | + </th> | |
| 258 | + {r.cells.map((c, i) => ( | |
| 259 | + <td key={PERMS[i]?.key} className="num tnum text-xs"> | |
| 260 | + {r.n === 0 ? ( | |
| 261 | + <span className="text-ink-3">—</span> | |
| 262 | + ) : ( | |
| 263 | + <span className="inline-flex items-center gap-1.5"> | |
| 264 | + <span className="text-positive">{c.yes}</span> | |
| 265 | + <span className="text-danger">{c.no}</span> | |
| 266 | + <span className="text-ink-3">{c.unk}</span> | |
| 267 | + </span> | |
| 268 | + )} | |
| 269 | + </td> | |
| 270 | + ))} | |
| 271 | + </tr> | |
| 272 | + ))} | |
| 273 | + </tbody> | |
| 274 | + </table> | |
| 275 | + <p className="mt-1.5 text-[11px] text-ink-3"> | |
| 276 | + Counts read: <span className="text-positive">allowed</span> · <span className="text-danger">restricted</span> · <span>unknown / unclassified</span> — over the {fmtInt(items.length)} models on this page, from each licence's stated terms (ontology). | |
| 277 | + </p> | |
| 278 | + </div> | |
| 279 | + </div> | |
| 280 | + </Section> | |
| 281 | + | |
| 282 | + {/* --------------------------------------------------------------------------------------- table */} | |
| 283 | + <Section eyebrow="Downloadable models" title={`${fmtInt(res.total)} models · sorted by ${SORT_LABELS[sort].toLowerCase()}`} lede={<LicenceLegend />}> | |
| 284 | + <DataTable scroll compact> | |
| 285 | + <thead> | |
| 286 | + <tr> | |
| 287 | + <SortTh active={sort === 'name'} href={href({ sort: 'name', offset: undefined })}>Model</SortTh> | |
| 288 | + <Th>Licence · permissions</Th> | |
| 289 | + <SortTh active={sort === 'params'} href={href({ sort: 'params', offset: undefined })} num dir="desc">Params</SortTh> | |
| 290 | + <SortTh active={sort === 'context'} href={href({ sort: 'context', offset: undefined })} num dir="desc">Context</SortTh> | |
| 291 | + <SortTh active={sort === 'release'} href={href({ sort: 'release', offset: undefined })} dir="desc">Release</SortTh> | |
| 292 | + <SortTh active={sort === 'rank'} href={href({ sort: undefined, offset: undefined })}>Best results</SortTh> | |
| 293 | + <Th num>Providers</Th> | |
| 294 | + <Th> | |
| 295 | + Fit 64 GB @4bit · 128 GB @8bit <Estimated className="ml-1 align-middle" /> | |
| 296 | + </Th> | |
| 297 | + <Th className="w-40" aria-label="Actions" /> | |
| 298 | + </tr> | |
| 299 | + </thead> | |
| 300 | + <tbody> | |
| 301 | + {items.length === 0 && <EmptyRow cols={9}>No downloadable model matches these filters.</EmptyRow>} | |
| 302 | + {items.map((it) => { | |
| 303 | + const d = it.dimensions ?? {}; | |
| 304 | + const mods = Array.isArray(d.modalities) ? (d.modalities as string[]) : []; | |
| 305 | + return ( | |
| 306 | + <tr key={it.model.id} data-open-row> | |
| 307 | + <Td primary> | |
| 308 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 309 | + <EntityLink e={it.model} /> | |
| 310 | + {typeof d.openness === 'string' && <OpennessBadge openness={d.openness} />} | |
| 311 | + </span> | |
| 312 | + <span className="block text-xs text-ink-3"> | |
| 313 | + {it.model.organization?.name ?? '—'} | |
| 314 | + {mods.length ? ` · ${mods.join(', ')}` : ''} | |
| 315 | + </span> | |
| 316 | + </Td> | |
| 317 | + <Td label="Licence"> | |
| 318 | + <LicencePerms l={it.licence} /> | |
| 319 | + </Td> | |
| 320 | + <Td num label="Params" className="tnum"> | |
| 321 | + {fmtParams(d.parameter_count)} | |
| 322 | + {num(d.active_parameter_count) !== null && num(d.active_parameter_count) !== num(d.parameter_count) && <span className="block text-[10px] text-ink-3">{fmtParams(d.active_parameter_count)} active</span>} | |
| 323 | + </Td> | |
| 324 | + <Td num label="Context" className="tnum">{fmtTokens(d.context_length)}</Td> | |
| 325 | + <Td label="Release" className="tnum text-ink-2 whitespace-nowrap">{typeof d.release_date === 'string' ? fmtDate(d.release_date) : '—'}</Td> | |
| 326 | + <Td label="Best results"> | |
| 327 | + <RankChips ranks={it.best_results} max={3} /> | |
| 328 | + </Td> | |
| 329 | + <Td num label="Providers" className="tnum"> | |
| 330 | + {fmtInt(it.providers)} | |
| 331 | + {num(it.cheapest_output_per_mtok) !== null && <span className="block text-[10px] text-accent-2">from {fmtUsdPerM(it.cheapest_output_per_mtok)} out</span>} | |
| 332 | + </Td> | |
| 333 | + <Td label="Fit (estimated)" className="text-xs"> | |
| 334 | + <span className="flex flex-col gap-0.5"> | |
| 335 | + <FitCell fit={it.hardware_fit?.['4bit_64gb']} compact /> | |
| 336 | + <FitCell fit={it.hardware_fit?.['8bit_128gb']} compact /> | |
| 337 | + </span> | |
| 338 | + </Td> | |
| 339 | + <Td className="text-right"> | |
| 340 | + <span className="inline-flex flex-wrap justify-end gap-1"> | |
| 341 | + <CompareButton e={it.model} size="sm" /> | |
| 342 | + <WatchButton e={it.model} size="sm" /> | |
| 343 | + </span> | |
| 344 | + </Td> | |
| 345 | + </tr> | |
| 346 | + ); | |
| 347 | + })} | |
| 348 | + </tbody> | |
| 349 | + </DataTable> | |
| 350 | + <Pagination total={res.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 351 | + <Note className="mt-3"> | |
| 352 | + Best results = rank inside each benchmark's primary comparability group; several dimensions are shown side by side and never combined. Fit columns are estimates (64 GB device at 4-bit, 128 GB at 8-bit, 8K context) — method on <Link href="/methodology#estimates" className="link">/methodology</Link>. <Chip>unclassified</Chip> = the raw licence label is not yet mapped in the ontology. | |
| 353 | + </Note> | |
| 354 | + <Methodology text={res.note} /> | |
| 355 | + </Section> | |
| 356 | + </> | |
| 357 | + )} | |
| 358 | + <CompareTrayBar /> | |
| 359 | + </TerminalLayout> | |
| 360 | + ); | |
| 361 | +} | |
added
apps/web/src/app/prices/opengraph-image.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { intel, safe } from '@/lib/api'; | |
| 4 | +import { fmtInt, fmtUsdPerM, num } from '@/lib/format'; | |
| 5 | +import { SITE_NAME } from '@/lib/site'; | |
| 6 | + | |
| 7 | +export const runtime = 'nodejs'; | |
| 8 | +export const alt = `AI Price Index on ${SITE_NAME}`; | |
| 9 | +export const size = { width: 1200, height: 630 }; | |
| 10 | +export const contentType = 'image/png'; | |
| 11 | + | |
| 12 | +export default async function PricesOgImage() { | |
| 13 | + const idx = await safe(intel.priceIndex(30)); | |
| 14 | + const latest = (idx?.series ?? []).filter((p) => num(p.median_input) !== null || num(p.median_output) !== null).at(-1); | |
| 15 | + const counters: [string, string][] = latest | |
| 16 | + ? [ | |
| 17 | + ['Median input / 1M', fmtUsdPerM(latest.median_input)], | |
| 18 | + ['Median output / 1M', fmtUsdPerM(latest.median_output)], | |
| 19 | + ['Models priced', fmtInt(latest.models)], | |
| 20 | + ['Current offers', fmtInt(latest.offers)], | |
| 21 | + ] | |
| 22 | + : []; | |
| 23 | + return new ImageResponse(<Wallpaper eyebrow="Price history terminal" title="AI Price Index" subtitle="USD per 1M tokens, day by day, across every provider offer — medians, distribution, movers, listings and every offer with its history." counters={counters} footer="www.ai-atlas.co/prices" markPx={240} />, { ...size }); | |
| 24 | +} | |
modified
apps/web/src/app/prices/page.tsx
+308 −97
@@ -1,30 +1,33 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { Legend, LineChart, type Series } from '@/components/charts/charts'; | |
| 4 | −import { FilterBar } from '@/components/listing/filters'; | |
| 3 | +import { Fragment } from 'react'; | |
| 4 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 5 | +import { BTN_GHOST, CTRL, DistBars, Field, Methodology, SortTh } from '@/components/intelligence/bits'; | |
| 6 | +import { ExpandableOfferRow } from '@/components/intelligence/expand-price-row'; | |
| 7 | +import { PriceIndexChart } from '@/components/intelligence/price-index-chart'; | |
| 8 | +import { DataStrip, type StripItem, TerminalLayout } from '@/components/layout/terminal'; | |
| 5 | 9 | import { PriceMovers } from '@/components/prices/movers'; |
| 6 | −import { ScaleToggle } from '@/components/prices/scale-toggle'; | |
| 7 | −import { ChipRow } from '@/components/timeline/chip-row'; | |
| 8 | 10 | import { Chip } from '@/components/ui/badges'; |
| 9 | 11 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 10 | 12 | import { EntityLink } from '@/components/ui/entity'; |
| 11 | 13 | import { Pagination, withParams } from '@/components/ui/pagination'; |
| 12 | 14 | import { SourceCell } from '@/components/ui/provenance'; |
| 13 | −import { Container, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 15 | +import { Note, PageHeader, Section } from '@/components/ui/section'; | |
| 14 | 16 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 15 | −import { api, ApiError, safe } from '@/lib/api'; | |
| 17 | +import { api, ApiError, intel, safe } from '@/lib/api'; | |
| 16 | 18 | import { fmtAgo, fmtDate, fmtInt, fmtTokens, fmtUsdPerM, num } from '@/lib/format'; |
| 17 | −import { routes, SITE_NAME } from '@/lib/site'; | |
| 18 | −import type { Page, Price } from '@/lib/types'; | |
| 19 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 20 | +import type { CheapestFrontier, Page, Price } from '@/lib/types'; | |
| 19 | 21 | |
| 20 | 22 | export const revalidate = 300; |
| 21 | 23 | |
| 22 | 24 | type SP = Record<string, string | undefined>; |
| 23 | 25 | const LIMIT = 100; |
| 24 | 26 | const DAYS = [30, 90, 180, 365]; |
| 25 | −const SORTS = ['input', 'output', 'model', 'provider', 'observed'] as const; | |
| 27 | +const SORTS = ['input', 'output', 'cheapest_frontier', 'model', 'provider', 'observed'] as const; | |
| 26 | 28 | type Sort = (typeof SORTS)[number]; |
| 27 | −const KEYS = ['days', 'scale', 'sort', 'provider', 'model', 'offset'] as const; | |
| 29 | +const KEYS = ['days', 'scale', 'sort', 'provider', 'org', 'family', 'modality', 'model', 'offset'] as const; | |
| 30 | +const SORT_LABELS: Record<Sort, string> = { input: 'Cheapest input', output: 'Cheapest output', cheapest_frontier: 'Cheapest frontier output', model: 'Model', provider: 'Provider', observed: 'Recently observed' }; | |
| 28 | 31 | |
| 29 | 32 | function pick(sp: SP) { |
| 30 | 33 | const cur: Record<string, string | undefined> = {}; |
@@ -34,163 +37,368 @@ function pick(sp: SP) { | ||
| 34 | 37 | return { cur, days, sort, offset: Math.max(0, Number(cur.offset) || 0), scale: cur.scale === 'log' ? ('log' as const) : ('linear' as const) }; |
| 35 | 38 | } |
| 36 | 39 | |
| 40 | +const TITLE = 'AI Price Index — USD per 1M tokens, every provider, every day'; | |
| 37 | 41 | export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { |
| 38 | 42 | const { cur, days } = pick(await searchParams); |
| 39 | − const title = cur.provider || cur.model ? `AI Price Index — ${[cur.provider, cur.model].filter(Boolean).join(' · ')}` : 'AI Price Index — USD per 1M tokens across providers'; | |
| 43 | + const filtered = cur.provider || cur.model || cur.org || cur.family || cur.modality; | |
| 44 | + const title = filtered ? `AI prices — ${[cur.provider, cur.org, cur.family, cur.modality, cur.model].filter(Boolean).join(' · ')}` : TITLE; | |
| 45 | + const description = `Daily medians of published input, output, frontier, open-weight and embedding prices per 1M tokens across every provider offer AI Atlas tracks (last ${days} days), the distribution of current offers, the cheapest frontier model, price movers, new listings and delistings, and every current offer with its source and history.`; | |
| 40 | 46 | return { |
| 41 | 47 | title, |
| 42 | − description: `Median and minimum published input/output prices per 1M tokens across every provider AI Atlas tracks, over the last ${days} days, plus recent price changes and every current offer with its source.`, | |
| 48 | + description, | |
| 43 | 49 | alternates: { canonical: routes.prices() }, |
| 44 | − openGraph: { title: `${title} | ${SITE_NAME}` }, | |
| 45 | − robots: cur.provider || cur.model || cur.offset ? { index: false, follow: true } : undefined, | |
| 50 | + openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${routes.prices()}`, type: 'website', siteName: SITE_NAME }, | |
| 51 | + twitter: { card: 'summary_large_image', title, description }, | |
| 52 | + robots: filtered || cur.offset ? { index: false, follow: true } : undefined, | |
| 46 | 53 | }; |
| 47 | 54 | } |
| 48 | 55 | |
| 49 | −/** `/prices` 404s when the model/provider slug is unknown — keep that distinction instead of a generic "unavailable". */ | |
| 50 | −async function loadPrices(query: Record<string, string | number | undefined>): Promise<{ page: Page<Price> | null; notFound: boolean }> { | |
| 56 | +/** `/prices` 404s when a slug filter is unknown — keep that distinction instead of a generic "unavailable". */ | |
| 57 | +async function loadPrices(query: Record<string, string | number | undefined>): Promise<{ page: (Page<Price> & { methodology?: string }) | null; notFound: boolean; detail?: string | null }> { | |
| 51 | 58 | try { |
| 52 | − return { page: await api.prices(query), notFound: false }; | |
| 59 | + return { page: await intel.prices(query), notFound: false }; | |
| 53 | 60 | } catch (e) { |
| 54 | − return { page: null, notFound: e instanceof ApiError && e.notFound }; | |
| 61 | + return { page: null, notFound: e instanceof ApiError && e.notFound, detail: e instanceof ApiError ? e.detail : null }; | |
| 55 | 62 | } |
| 56 | 63 | } |
| 57 | 64 | |
| 65 | +function CheapestStrip({ c, label }: { c: CheapestFrontier | null | undefined; label: string }) { | |
| 66 | + return ( | |
| 67 | + <div className="min-w-0 py-3"> | |
| 68 | + <p className="eyebrow">{label}</p> | |
| 69 | + {c ? ( | |
| 70 | + <> | |
| 71 | + <p className="mt-1 flex flex-wrap items-baseline gap-x-2"> | |
| 72 | + <EntityLink e={c.model} className="text-[15px] font-medium" /> | |
| 73 | + {c.model.organization && <span className="text-xs text-ink-3">{c.model.organization.name}</span>} | |
| 74 | + </p> | |
| 75 | + <p className="tnum mt-1 text-[22px] font-semibold leading-none text-accent-2"> | |
| 76 | + {fmtUsdPerM(c.output)} <span className="text-xs font-normal text-ink-3">output / 1M</span> | |
| 77 | + </p> | |
| 78 | + <p className="tnum mt-1 text-xs text-ink-3"> | |
| 79 | + input {fmtUsdPerM(c.input)} · context {fmtTokens(c.context_length)} · via <EntityLink e={c.provider} className="text-ink-2" /> | |
| 80 | + </p> | |
| 81 | + </> | |
| 82 | + ) : ( | |
| 83 | + <p className="mt-1 text-sm text-ink-3">No frontier model with a current priced offer in this response.</p> | |
| 84 | + )} | |
| 85 | + </div> | |
| 86 | + ); | |
| 87 | +} | |
| 88 | + | |
| 58 | 89 | export default async function PricesPage({ searchParams }: { searchParams: Promise<SP> }) { |
| 59 | 90 | const sp = await searchParams; |
| 60 | 91 | const { cur, days, sort, offset, scale } = pick(sp); |
| 61 | − const [index, providers, priced] = await Promise.all([safe(api.priceIndex(days)), safe(api.providers()), loadPrices({ sort, provider: cur.provider, model: cur.model, limit: LIMIT, offset })]); | |
| 92 | + const [index, providers, facets, priced, listed, delisted] = await Promise.all([ | |
| 93 | + safe(intel.priceIndex(days)), | |
| 94 | + safe(intel.providers()), | |
| 95 | + safe(api.models({ limit: 1, facets: 1 })), | |
| 96 | + loadPrices({ sort, provider: cur.provider, model: cur.model, org: cur.org, family: cur.family, modality: cur.modality, limit: LIMIT, offset }), | |
| 97 | + safe(api.changes({ type: 'PROVIDER_LISTED', limit: 8 })), | |
| 98 | + safe(api.changes({ type: 'PROVIDER_DELISTED', limit: 8 })), | |
| 99 | + ]); | |
| 62 | 100 | const href = (patch: Record<string, string | number | undefined | null>) => withParams('/prices', cur, patch); |
| 101 | + const providerHref = (name: string) => { | |
| 102 | + const p = (providers?.items ?? []).find((x) => x.name === name); | |
| 103 | + return p ? href({ provider: p.slug, offset: undefined }) : undefined; | |
| 104 | + }; | |
| 63 | 105 | |
| 64 | − // ---- index: latest populated day + chart series (null days skipped) | |
| 106 | + // ---- index | |
| 65 | 107 | const series = index?.series ?? []; |
| 66 | 108 | const populated = series.filter((p) => num(p.median_input) !== null || num(p.median_output) !== null); |
| 67 | 109 | const latest = populated.at(-1) ?? null; |
| 68 | 110 | const first = populated[0] ?? null; |
| 69 | − const toSeries = (name: string, field: 'median_input' | 'median_output' | 'min_input', color: string): Series => ({ name, color, points: series.flatMap((p) => (num(p[field]) === null ? [] : [{ x: new Date(`${p.day}T00:00:00Z`), y: num(p[field]) as number }])) }); | |
| 70 | − const chartSeries = [toSeries('Median input', 'median_input', 'var(--series-1)'), toSeries('Median output', 'median_output', 'var(--series-2)'), toSeries('Cheapest input', 'min_input', 'var(--series-7)')].filter((s) => s.points.length > 0); | |
| 71 | − const chartPoints = chartSeries.reduce((n, s) => n + s.points.length, 0); | |
| 72 | − const delta = (field: 'median_input' | 'median_output') => { | |
| 111 | + const delta = (field: 'median_input' | 'median_output' | 'median_frontier_output' | 'median_open_output' | 'median_embedding_input') => { | |
| 73 | 112 | const a = num(first?.[field]); |
| 74 | 113 | const b = num(latest?.[field]); |
| 75 | 114 | if (a === null || b === null || a === 0 || first === latest) return undefined; |
| 76 | 115 | const pct = ((b - a) / a) * 100; |
| 116 | + if (Math.abs(pct) < 0.05) return undefined; | |
| 77 | 117 | return { value: `${pct > 0 ? '+' : ''}${pct.toFixed(1)}%`, tone: pct < 0 ? ('positive' as const) : pct > 0 ? ('negative' as const) : ('neutral' as const) }; |
| 78 | 118 | }; |
| 119 | + const money = (v: unknown) => <span className="text-accent-2">{fmtUsdPerM(v)}</span>; | |
| 120 | + const strip: StripItem[] | null = latest | |
| 121 | + ? [ | |
| 122 | + { label: 'Median input', value: money(latest.median_input), definition: 'Median of live input prices (USD / 1M tokens) across every provider offer valid at the end of the day; zero or missing prices excluded.', delta: delta('median_input'), hint: `n=${fmtInt(latest.sample?.offers ?? latest.offers)}` }, | |
| 123 | + { label: 'Median output', value: money(latest.median_output), definition: 'Median of live output prices across every provider offer valid at the end of the day.', delta: delta('median_output') }, | |
| 124 | + { label: 'Median frontier output', value: money(latest.median_frontier_output), definition: index?.frontier?.methodology ?? 'Frontier models = recent releases by active organizations or top-10 on a benchmark; no composite score.', delta: delta('median_frontier_output'), hint: `n=${fmtInt(latest.sample?.frontier_offers)}` }, | |
| 125 | + { label: 'Median open output', value: money(latest.median_open_output), definition: 'Median output price over models with openness open-weights / open-source.', delta: delta('median_open_output'), hint: `n=${fmtInt(latest.sample?.open_models)}` }, | |
| 126 | + { label: 'Median embedding input', value: money(latest.median_embedding_input), definition: 'Median input price over models whose modalities include embedding.', delta: delta('median_embedding_input'), hint: `n=${fmtInt(latest.sample?.embedding_models)}` }, | |
| 127 | + { label: 'Models priced', value: fmtInt(latest.models), definition: 'Canonical models with at least one live offer on the latest day.', hint: fmtDate(latest.day) }, | |
| 128 | + { label: 'Cheapest input', value: fmtUsdPerM(latest.min_input), definition: 'Lowest positive live input price on the latest day.', hint: num(latest.max_input) !== null ? `dearest ${fmtUsdPerM(latest.max_input)}` : undefined }, | |
| 129 | + ] | |
| 130 | + : null; | |
| 79 | 131 | |
| 80 | − // ---- table: cheapest input per model within the shown rows | |
| 132 | + // ---- offers | |
| 81 | 133 | const page = priced.page; |
| 82 | 134 | const rows = page?.items ?? []; |
| 83 | 135 | const minByModel = new Map<string, number>(); |
| 84 | − for (const r of rows) { | |
| 85 | − const v = num(r.input_per_mtok); | |
| 86 | − if (v === null) continue; | |
| 87 | − const m = minByModel.get(r.model.slug); | |
| 88 | − if (m === undefined || v < m) minByModel.set(r.model.slug, v); | |
| 89 | − } | |
| 90 | − // "cheapest" is only meaningful when a model is offered by more than one provider among the shown rows | |
| 91 | 136 | const providersByModel = new Map<string, Set<string>>(); |
| 92 | 137 | for (const r of rows) { |
| 138 | + const v = num(r.input_per_mtok); | |
| 139 | + if (v !== null) { | |
| 140 | + const m = minByModel.get(r.model.slug); | |
| 141 | + if (m === undefined || v < m) minByModel.set(r.model.slug, v); | |
| 142 | + } | |
| 93 | 143 | const s = providersByModel.get(r.model.slug) ?? new Set<string>(); |
| 94 | 144 | s.add(r.provider.slug); |
| 95 | 145 | providersByModel.set(r.model.slug, s); |
| 96 | 146 | } |
| 97 | − const providerOptions = (providers?.items ?? []).slice().sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: p.slug, label: p.name })); | |
| 98 | − const providerName = providerOptions.find((o) => o.value === cur.provider)?.label; | |
| 99 | − const SortTh = ({ s, children, num: n }: { s: Sort; children: React.ReactNode; num?: boolean }) => ( | |
| 100 | − <Th num={n} aria-sort={sort === s ? 'ascending' : undefined}> | |
| 101 | − <Link href={href({ sort: s === 'input' ? undefined : s, offset: undefined })} className={sort === s ? 'text-ink' : 'hover:text-ink'}> | |
| 102 | − {children} | |
| 103 | − {sort === s && <span aria-hidden> ↑</span>} | |
| 104 | − </Link> | |
| 105 | − </Th> | |
| 147 | + const providerOptions = (providers?.items ?? []).slice().sort((a, b) => a.name.localeCompare(b.name)); | |
| 148 | + const providerName = providerOptions.find((o) => o.slug === cur.provider)?.name; | |
| 149 | + const orgOptions = ((facets?.facets as { organizations?: { slug: string; name: string; count?: unknown }[] } | undefined)?.organizations ?? []).slice(0, 60); | |
| 150 | + const familyOptions = ((facets?.facets as { families?: { value: string; label?: string; count?: unknown }[] } | undefined)?.families ?? []).slice(0, 60); | |
| 151 | + const modalityOptions = ((facets?.facets as { modalities?: { value: string; count?: unknown }[] } | undefined)?.modalities ?? []); | |
| 152 | + const filterCount = ['provider', 'org', 'family', 'modality', 'model'].filter((k) => cur[k]).length + (days !== 180 ? 1 : 0); | |
| 153 | + const newListings = index?.new_listings_30d; | |
| 154 | + const delistings = index?.delistings_30d; | |
| 155 | + const changes30 = index?.price_changes_30d; | |
| 156 | + const asCount = (v: unknown) => (Array.isArray(v) ? v.length : num(v)); | |
| 157 | + | |
| 158 | + const filters = ( | |
| 159 | + <form action="/prices" method="get" className="space-y-3" data-price-filters> | |
| 160 | + <Field label="Provider"> | |
| 161 | + <select name="provider" defaultValue={cur.provider ?? ''} className={CTRL}> | |
| 162 | + <option value="">Any provider</option> | |
| 163 | + {providerOptions.map((p) => ( | |
| 164 | + <option key={p.slug} value={p.slug}> | |
| 165 | + {p.name} | |
| 166 | + </option> | |
| 167 | + ))} | |
| 168 | + </select> | |
| 169 | + </Field> | |
| 170 | + <Field label="Organization"> | |
| 171 | + <select name="org" defaultValue={cur.org ?? ''} className={CTRL}> | |
| 172 | + <option value="">Any organization</option> | |
| 173 | + {orgOptions.map((o) => ( | |
| 174 | + <option key={o.slug} value={o.slug}> | |
| 175 | + {o.name} | |
| 176 | + </option> | |
| 177 | + ))} | |
| 178 | + {cur.org && !orgOptions.some((o) => o.slug === cur.org) && <option value={cur.org}>{cur.org}</option>} | |
| 179 | + </select> | |
| 180 | + </Field> | |
| 181 | + <Field label="Family"> | |
| 182 | + <select name="family" defaultValue={cur.family ?? ''} className={CTRL}> | |
| 183 | + <option value="">Any family</option> | |
| 184 | + {familyOptions.map((f) => ( | |
| 185 | + <option key={f.value} value={f.value}> | |
| 186 | + {f.label ?? f.value} | |
| 187 | + </option> | |
| 188 | + ))} | |
| 189 | + {cur.family && !familyOptions.some((f) => f.value === cur.family) && <option value={cur.family}>{cur.family}</option>} | |
| 190 | + </select> | |
| 191 | + </Field> | |
| 192 | + <Field label="Modality"> | |
| 193 | + <select name="modality" defaultValue={cur.modality ?? ''} className={CTRL}> | |
| 194 | + <option value="">Any modality</option> | |
| 195 | + {modalityOptions.map((m) => ( | |
| 196 | + <option key={m.value} value={m.value}> | |
| 197 | + {m.value} | |
| 198 | + </option> | |
| 199 | + ))} | |
| 200 | + </select> | |
| 201 | + </Field> | |
| 202 | + <Field label="Model slug" hint="Slugs only — the API 404s on free text."> | |
| 203 | + <input name="model" defaultValue={cur.model ?? ''} placeholder="e.g. claude-opus-5" className={CTRL} /> | |
| 204 | + </Field> | |
| 205 | + <Field label="Index window"> | |
| 206 | + <select name="days" defaultValue={String(days)} className={CTRL}> | |
| 207 | + {DAYS.map((d) => ( | |
| 208 | + <option key={d} value={d}> | |
| 209 | + Last {d} days | |
| 210 | + </option> | |
| 211 | + ))} | |
| 212 | + </select> | |
| 213 | + </Field> | |
| 214 | + <Field label="Sort offers"> | |
| 215 | + <select name="sort" defaultValue={sort} className={CTRL}> | |
| 216 | + {SORTS.map((s) => ( | |
| 217 | + <option key={s} value={s}> | |
| 218 | + {SORT_LABELS[s]} | |
| 219 | + </option> | |
| 220 | + ))} | |
| 221 | + </select> | |
| 222 | + </Field> | |
| 223 | + {cur.scale && <input type="hidden" name="scale" value={cur.scale} />} | |
| 224 | + <div className="flex gap-2"> | |
| 225 | + <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink lg:h-10 px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 226 | + Apply | |
| 227 | + </button> | |
| 228 | + <Link href="/prices" className={BTN_GHOST}> | |
| 229 | + Reset | |
| 230 | + </Link> | |
| 231 | + </div> | |
| 232 | + </form> | |
| 106 | 233 | ); |
| 107 | 234 | |
| 108 | − return ( | |
| 109 | − <Container wide> | |
| 110 | − <PageHeader eyebrow="Prices" title="AI Price Index" lede="What a million tokens costs, day by day: the median and cheapest published input/output prices across every provider offer AI Atlas tracks. Prices are quoted as stated on providers' pricing pages; every change is kept." aside={latest ? <p className="tnum text-sm text-ink-3">{fmtInt(latest.offers)} offers · {fmtInt(latest.models)} models · {fmtDate(latest.day)}</p> : undefined}> | |
| 111 | − <div className="mt-6"> | |
| 112 | − <p className="eyebrow mb-1.5">Window</p> | |
| 113 | − <ChipRow label="Window" items={DAYS.map((d) => ({ href: href({ days: d === 180 ? undefined : d }), label: `${d} days`, active: days === d }))} /> | |
| 235 | + const inspector = ( | |
| 236 | + <div className="space-y-4 text-xs leading-relaxed text-ink-2"> | |
| 237 | + <div> | |
| 238 | + <p className="eyebrow mb-1">Index method</p> | |
| 239 | + <p>{index?.methodology ?? index?.note ?? 'Unavailable.'}</p> | |
| 240 | + </div> | |
| 241 | + {latest?.sample && ( | |
| 242 | + <div> | |
| 243 | + <p className="eyebrow mb-1">Sample · {fmtDate(latest.day)}</p> | |
| 244 | + <dl className="tnum grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5"> | |
| 245 | + <dt className="text-ink-3">offers</dt> | |
| 246 | + <dd>{fmtInt(latest.sample.offers)}</dd> | |
| 247 | + <dt className="text-ink-3">models</dt> | |
| 248 | + <dd>{fmtInt(latest.sample.models)}</dd> | |
| 249 | + <dt className="text-ink-3">frontier</dt> | |
| 250 | + <dd> | |
| 251 | + {fmtInt(latest.sample.frontier_models)} models · {fmtInt(latest.sample.frontier_offers)} offers | |
| 252 | + </dd> | |
| 253 | + <dt className="text-ink-3">open</dt> | |
| 254 | + <dd>{fmtInt(latest.sample.open_models)} models</dd> | |
| 255 | + <dt className="text-ink-3">embedding</dt> | |
| 256 | + <dd>{fmtInt(latest.sample.embedding_models)} models</dd> | |
| 257 | + </dl> | |
| 258 | + </div> | |
| 259 | + )} | |
| 260 | + {index?.frontier?.composition && ( | |
| 261 | + <div> | |
| 262 | + <p className="eyebrow mb-1">Frontier composition</p> | |
| 263 | + <dl className="tnum grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5"> | |
| 264 | + {Object.entries(index.frontier.composition).map(([k, v]) => ( | |
| 265 | + <Fragment key={k}> | |
| 266 | + <dt className="text-ink-3">{k.replace(/_/g, ' ')}</dt> | |
| 267 | + <dd>{typeof v === 'string' && /^\d{4}-/.test(v) ? fmtDate(v) : fmtInt(v)}</dd> | |
| 268 | + </Fragment> | |
| 269 | + ))} | |
| 270 | + </dl> | |
| 114 | 271 | </div> |
| 115 | − </PageHeader> | |
| 272 | + )} | |
| 273 | + <div> | |
| 274 | + <p className="eyebrow mb-1">30-day counters</p> | |
| 275 | + <dl className="tnum grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5"> | |
| 276 | + <dt className="text-ink-3">new listings</dt> | |
| 277 | + <dd>{fmtInt(asCount(newListings))}</dd> | |
| 278 | + <dt className="text-ink-3">delistings</dt> | |
| 279 | + <dd>{fmtInt(asCount(delistings))}</dd> | |
| 280 | + <dt className="text-ink-3">price changes</dt> | |
| 281 | + <dd>{fmtInt(asCount(changes30))}</dd> | |
| 282 | + </dl> | |
| 283 | + </div> | |
| 284 | + <p> | |
| 285 | + <Link href="/developers" className="link">GET /prices/index</Link> · <Link href="/methodology" className="link">methodology</Link> | |
| 286 | + </p> | |
| 287 | + </div> | |
| 288 | + ); | |
| 289 | + | |
| 290 | + return ( | |
| 291 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Method" storageKey="aia-inspector-prices" filterCount={filterCount}> | |
| 292 | + <PageHeader eyebrow="Price history terminal" title="AI Price Index" lede="What a million tokens costs, day by day, across every provider offer the atlas tracks — medians for the whole market, the frontier, open weights and embeddings; the distribution of current offers; movers, listings and every offer with its history." aside={latest ? <p className="tnum text-sm text-ink-3">{fmtInt(latest.offers)} offers · {fmtInt(latest.models)} models · {fmtDate(latest.day)}</p> : undefined} className="pt-4 md:pt-6" /> | |
| 116 | 293 | |
| 117 | 294 | {/* ------------------------------------------------------------------------------------------------ index */} |
| 118 | − <Section eyebrow="Index" title={<>Median price per 1M tokens · last {days} days</>} hairline={false}> | |
| 295 | + <Section eyebrow="AI Price Index" title={<>Daily medians · last {days} days</>} hairline={false} className="pt-0"> | |
| 119 | 296 | {!index ? ( |
| 120 | 297 | <Unavailable what="Price index" /> |
| 121 | 298 | ) : !latest ? ( |
| 122 | 299 | <EmptyState title="No priced offers in this window">Prices appear once a provider pricing page has been crawled. Try a longer window.</EmptyState> |
| 123 | 300 | ) : ( |
| 124 | 301 | <> |
| 125 | − <StatGrid cols={5}> | |
| 126 | − <Stat label="Median input" value={<span className="text-accent-2">{fmtUsdPerM(latest.median_input)}</span>} hint={`per 1M · ${fmtDate(latest.day)}`} delta={delta('median_input')} /> | |
| 127 | − <Stat label="Median output" value={<span className="text-accent-2">{fmtUsdPerM(latest.median_output)}</span>} hint={`per 1M · ${fmtDate(latest.day)}`} delta={delta('median_output')} /> | |
| 128 | − <Stat label="Cheapest input" value={fmtUsdPerM(latest.min_input)} hint={num(latest.max_input) !== null ? `dearest ${fmtUsdPerM(latest.max_input)}` : 'per 1M tokens'} /> | |
| 129 | − <Stat label="Models priced" value={fmtInt(latest.models)} hint="with a current offer" /> | |
| 130 | − <Stat label="Offers" value={fmtInt(latest.offers)} hint="model × provider rows" /> | |
| 131 | − </StatGrid> | |
| 132 | − <div className="mt-6"> | |
| 133 | − {chartPoints < 2 || populated.length < 2 ? ( | |
| 134 | − <Note>The index has {fmtInt(populated.length)} day{populated.length === 1 ? '' : 's'} of observations so far — a line needs at least two. The chart fills in as daily snapshots accumulate.</Note> | |
| 135 | − ) : ( | |
| 136 | − <> | |
| 137 | − <ScaleToggle initial={scale} linear={<LineChart series={chartSeries} height={240} yFormat={(v) => fmtUsdPerM(v)} yLabel="USD per 1M tokens" showDots />} log={<LineChart series={chartSeries} height={240} yFormat={(v) => fmtUsdPerM(v)} yLabel="USD per 1M tokens (log scale)" showDots yScale="log" />} /> | |
| 138 | − <Legend series={chartSeries} className="mt-2" /> | |
| 139 | − </> | |
| 140 | − )} | |
| 141 | − </div> | |
| 142 | − {index.note && <Note className="mt-3">{index.note} {delta('median_input') ? 'Deltas compare the first and last populated day of the window.' : ''}</Note>} | |
| 302 | + {strip && <DataStrip items={strip} dense />} | |
| 303 | + <PriceIndexChart series={series} initialScale={scale} className="mt-5" /> | |
| 304 | + <p className="tnum mt-2 text-xs text-ink-3"> | |
| 305 | + {fmtInt(populated.length)} populated day{populated.length === 1 ? '' : 's'} in the window · sample sizes per day in the tooltip · a null median means no offer in that universe on that day. | |
| 306 | + </p> | |
| 307 | + <Methodology text={index.methodology ?? index.note} /> | |
| 143 | 308 | </> |
| 144 | 309 | )} |
| 145 | 310 | </Section> |
| 146 | 311 | |
| 312 | + {/* ------------------------------------------------------------------------------------- distribution + frontier */} | |
| 313 | + {index && ( | |
| 314 | + <Section eyebrow="Current offers" title="Distribution and the cheapest frontier"> | |
| 315 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-8 gap-y-4 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)]"> | |
| 316 | + <div> | |
| 317 | + <p className="eyebrow">Current offers by output price</p> | |
| 318 | + <DistBars d={index.distribution} className="mt-2" /> | |
| 319 | + </div> | |
| 320 | + <CheapestStrip c={index.cheapest_frontier} label="Cheapest frontier output" /> | |
| 321 | + <CheapestStrip c={index.cheapest_frontier_1m_context} label="Cheapest frontier with ≥ 1M context" /> | |
| 322 | + </div> | |
| 323 | + <Methodology text={index.frontier?.methodology} /> | |
| 324 | + </Section> | |
| 325 | + )} | |
| 326 | + | |
| 147 | 327 | {/* ------------------------------------------------------------------------------------------------ movers */} |
| 148 | − <Section eyebrow="Movers" title="Recent price changes" lede="Provider offers whose published price moved, newest first." action={{ href: `${routes.changes()}?category=price`, label: 'All pricing events' }}> | |
| 149 | − {!index ? <Unavailable what="Price changes" /> : <PriceMovers movers={index.movers ?? []} />} | |
| 328 | + <Section eyebrow="Movers" title="Recent price changes" lede="Provider offers whose published price moved, newest first, with the relative change." action={{ href: `${routes.changes()}?type=PRICE_CHANGED`, label: 'All pricing events' }}> | |
| 329 | + {!index ? <Unavailable what="Price changes" /> : <PriceMovers movers={index.movers ?? []} providerHref={providerHref} />} | |
| 150 | 330 | </Section> |
| 151 | 331 | |
| 152 | − {/* ------------------------------------------------------------------------------------------------ table */} | |
| 153 | − <Section eyebrow="Current prices" title={providerName ? `Current offers from ${providerName}` : cur.model ? `Current offers for ${cur.model}` : 'Every current offer'} lede="One row per model × provider, as published. Sort by any column; filter by provider or model slug."> | |
| 154 | − <FilterBar | |
| 155 | − action="/prices" | |
| 156 | − className="mb-5 lg:grid-cols-5" | |
| 157 | − resetHref={href({ provider: undefined, model: undefined, offset: undefined, sort: undefined })} | |
| 158 | − fields={[ | |
| 159 | − { kind: 'select', name: 'provider', label: 'Provider', value: cur.provider, options: providerOptions }, | |
| 160 | − { kind: 'text', name: 'model', label: 'Model slug', value: cur.model, placeholder: 'e.g. claude-opus-5' }, | |
| 161 | − ...(cur.days ? [{ kind: 'hidden' as const, name: 'days', value: cur.days }] : []), | |
| 162 | − ...(cur.scale ? [{ kind: 'hidden' as const, name: 'scale', value: cur.scale }] : []), | |
| 163 | − ]} | |
| 164 | − sort={{ value: sort, options: [{ value: 'input', label: 'Cheapest input' }, { value: 'output', label: 'Cheapest output' }, { value: 'model', label: 'Model' }, { value: 'provider', label: 'Provider' }, { value: 'observed', label: 'Recently observed' }] }} | |
| 165 | − /> | |
| 332 | + {/* ---------------------------------------------------------------------------------- listings / delistings */} | |
| 333 | + <Section eyebrow="Listings · 30 days" title="New listings and delistings"> | |
| 334 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-8 md:grid-cols-2"> | |
| 335 | + <div> | |
| 336 | + <p className="flex items-baseline justify-between"> | |
| 337 | + <span className="text-sm font-medium text-ink">New listings</span> | |
| 338 | + <span className="tnum text-sm text-ink-3">{fmtInt(asCount(newListings))} in 30 d</span> | |
| 339 | + </p> | |
| 340 | + <p className="mt-0.5 text-xs text-ink-3">PROVIDER_LISTED events that occurred in the last 30 days (a model × provider offer first opened).</p> | |
| 341 | + {Array.isArray(newListings) && newListings.length ? ( | |
| 342 | + <ul className="mt-2 border-t border-rule">{newListings.slice(0, 8).map((e) => <ChangeRow key={e.id} e={e} dense live={false} showDate />)}</ul> | |
| 343 | + ) : listed?.items.length ? ( | |
| 344 | + <ul className="mt-2 border-t border-rule">{listed.items.map((e) => <ChangeRow key={e.id} e={e} dense live={false} showDate />)}</ul> | |
| 345 | + ) : ( | |
| 346 | + <p className="mt-2 text-sm text-ink-3">No listing event in the window.</p> | |
| 347 | + )} | |
| 348 | + <p className="mt-2 text-xs"> | |
| 349 | + <Link href={`${routes.changes()}?type=PROVIDER_LISTED`} className="link">All listing events →</Link> | |
| 350 | + </p> | |
| 351 | + </div> | |
| 352 | + <div> | |
| 353 | + <p className="flex items-baseline justify-between"> | |
| 354 | + <span className="text-sm font-medium text-ink">Delistings</span> | |
| 355 | + <span className="tnum text-sm text-ink-3">{fmtInt(asCount(delistings))} in 30 d</span> | |
| 356 | + </p> | |
| 357 | + <p className="mt-0.5 text-xs text-ink-3">PROVIDER_DELISTED events that occurred in the last 30 days (an offer closed).</p> | |
| 358 | + {Array.isArray(delistings) && delistings.length ? ( | |
| 359 | + <ul className="mt-2 border-t border-rule">{delistings.slice(0, 8).map((e) => <ChangeRow key={e.id} e={e} dense live={false} showDate />)}</ul> | |
| 360 | + ) : delisted?.items.length ? ( | |
| 361 | + <ul className="mt-2 border-t border-rule">{delisted.items.map((e) => <ChangeRow key={e.id} e={e} dense live={false} showDate />)}</ul> | |
| 362 | + ) : ( | |
| 363 | + <p className="mt-2 text-sm text-ink-3">No delisting recorded in the window — offers that disappear from a pricing page are closed (valid_to) and emit PROVIDER_DELISTED.</p> | |
| 364 | + )} | |
| 365 | + </div> | |
| 366 | + </div> | |
| 367 | + </Section> | |
| 368 | + | |
| 369 | + {/* ------------------------------------------------------------------------------------------------ offers */} | |
| 370 | + <Section eyebrow="Current offers" title={providerName ? `Every current offer from ${providerName}` : cur.model ? `Current offers for ${cur.model}` : 'Every current offer'} lede="One row per model × provider, as published. Expand a row to load its price history (sparklines). Sort with the column headers or the rail."> | |
| 166 | 371 | {priced.notFound ? ( |
| 167 | − <EmptyState title={`No ${cur.model ? 'model' : 'provider'} with slug “${cur.model ?? cur.provider}”`}> | |
| 372 | + <EmptyState title={`No ${cur.model ? 'model' : cur.provider ? 'provider' : 'entity'} with slug “${cur.model ?? cur.provider ?? cur.org ?? cur.family}”`}> | |
| 168 | 373 | Slugs are the last part of an entity URL (<span className="mono">/models/<slug></span>). <Link href={cur.model ? routes.search(cur.model, 'model') : routes.providers()} className="link">Search instead →</Link> |
| 169 | 374 | </EmptyState> |
| 170 | 375 | ) : !page ? ( |
| 171 | − <Unavailable what="Current prices" /> | |
| 376 | + <Unavailable what="Current prices" reason={priced.detail ?? undefined} /> | |
| 172 | 377 | ) : ( |
| 173 | 378 | <> |
| 174 | − <DataTable caption="Current prices per 1M tokens"> | |
| 379 | + {page.methodology && sort === 'cheapest_frontier' && <Methodology text={page.methodology} className="mb-3 mt-0" />} | |
| 380 | + <DataTable scroll compact> | |
| 175 | 381 | <thead> |
| 176 | 382 | <tr> |
| 177 | − <SortTh s="model">Model</SortTh> | |
| 178 | − <SortTh s="provider">Provider</SortTh> | |
| 179 | − <SortTh s="input" num>Input / 1M</SortTh> | |
| 180 | − <SortTh s="output" num>Output / 1M</SortTh> | |
| 383 | + <SortTh active={sort === 'model'} href={href({ sort: 'model', offset: undefined })}>Model</SortTh> | |
| 384 | + <SortTh active={sort === 'provider'} href={href({ sort: 'provider', offset: undefined })}>Provider</SortTh> | |
| 385 | + <SortTh active={sort === 'input'} href={href({ sort: undefined, offset: undefined })} num>Input / 1M</SortTh> | |
| 181 | 386 | <Th num>Cached in</Th> |
| 387 | + <SortTh active={sort === 'output' || sort === 'cheapest_frontier'} href={href({ sort: 'output', offset: undefined })} num>Output / 1M</SortTh> | |
| 388 | + <Th num>Batch in / out</Th> | |
| 182 | 389 | <Th num>Context</Th> |
| 183 | − <SortTh s="observed">Observed</SortTh> | |
| 390 | + <SortTh active={sort === 'observed'} href={href({ sort: 'observed', offset: undefined })} dir="desc">Observed</SortTh> | |
| 184 | 391 | <Th>Source</Th> |
| 392 | + <Th className="w-24" aria-label="History" /> | |
| 185 | 393 | </tr> |
| 186 | 394 | </thead> |
| 187 | 395 | <tbody> |
| 188 | − {rows.length === 0 && <EmptyRow cols={8}>No current offers match these filters.</EmptyRow>} | |
| 396 | + {rows.length === 0 && <EmptyRow cols={10}>{sort === 'cheapest_frontier' ? 'No frontier model has a current priced offer in the API response (see the methodology above).' : 'No current offers match these filters.'}</EmptyRow>} | |
| 189 | 397 | {rows.map((p) => { |
| 190 | 398 | const v = num(p.input_per_mtok); |
| 191 | 399 | const cheapest = v !== null && minByModel.get(p.model.slug) === v && (providersByModel.get(p.model.slug)?.size ?? 0) > 1; |
| 192 | 400 | return ( |
| 193 | − <tr key={p.id}> | |
| 401 | + <ExpandableOfferRow key={p.id} model={p.model.slug} provider={p.provider.slug} colSpan={9}> | |
| 194 | 402 | <Td primary> |
| 195 | 403 | <EntityLink e={p.model} /> |
| 196 | 404 | {p.model.organization && <span className="ml-2 text-xs text-ink-3">{p.model.organization.name}</span>} |
@@ -206,23 +414,26 @@ export default async function PricesPage({ searchParams }: { searchParams: Promi | ||
| 206 | 414 | {fmtUsdPerM(p.input_per_mtok)} |
| 207 | 415 | {cheapest && <Chip tone="accent" className="ml-1.5 align-middle">cheapest</Chip>} |
| 208 | 416 | </Td> |
| 209 | − <Td num label="Output / 1M" className="tnum text-accent-2">{fmtUsdPerM(p.output_per_mtok)}</Td> | |
| 210 | 417 | <Td num label="Cached in" className="tnum text-ink-2">{fmtUsdPerM(p.cached_input_per_mtok)}</Td> |
| 418 | + <Td num label="Output / 1M" className="tnum text-accent-2">{fmtUsdPerM(p.output_per_mtok)}</Td> | |
| 419 | + <Td num label="Batch" className="tnum text-ink-2"> | |
| 420 | + {num(p.batch_input_per_mtok) === null && num(p.batch_output_per_mtok) === null ? <span className="text-ink-3">—</span> : `${fmtUsdPerM(p.batch_input_per_mtok)} / ${fmtUsdPerM(p.batch_output_per_mtok)}`} | |
| 421 | + </Td> | |
| 211 | 422 | <Td num label="Context" className="tnum text-ink-2">{num(p.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(p.context_length)}</Td> |
| 212 | 423 | <Td label="Observed" className="text-ink-2" title={p.observed_at}>{fmtAgo(p.observed_at)}</Td> |
| 213 | 424 | <Td label="Source"><SourceCell url={p.source_url} tier={p.tier} /></Td> |
| 214 | − </tr> | |
| 425 | + </ExpandableOfferRow> | |
| 215 | 426 | ); |
| 216 | 427 | })} |
| 217 | 428 | </tbody> |
| 218 | 429 | </DataTable> |
| 219 | 430 | <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> |
| 220 | 431 | <Note className="mt-3"> |
| 221 | − “Cheapest” marks the lowest input price for a model among the rows shown on this page (models served by several providers). USD per 1M tokens as published; <Link href={routes.providers()} className="link">providers overview</Link> · <Link href="/methodology" className="link">methodology</Link>. | |
| 432 | + “Cheapest” marks the lowest input price for a model among the rows on this page (models served by several providers). USD per 1M tokens as published; <Link href={routes.providers()} className="link">providers overview</Link> · <Link href={routes.calculator()} className="link">cost calculator</Link> · <Link href="/methodology" className="link">methodology</Link>. | |
| 222 | 433 | </Note> |
| 223 | 434 | </> |
| 224 | 435 | )} |
| 225 | 436 | </Section> |
| 226 | − </Container> | |
| 437 | + </TerminalLayout> | |
| 227 | 438 | ); |
| 228 | 439 | } |
added
apps/web/src/app/providers/[slug]/opengraph-image.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Fallback, Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { intel, safe } from '@/lib/api'; | |
| 4 | +import { fmtInt, fmtUsdPerM } from '@/lib/format'; | |
| 5 | +import { SITE_NAME } from '@/lib/site'; | |
| 6 | + | |
| 7 | +export const runtime = 'nodejs'; | |
| 8 | +export const alt = `Provider on ${SITE_NAME}`; | |
| 9 | +export const size = { width: 1200, height: 630 }; | |
| 10 | +export const contentType = 'image/png'; | |
| 11 | + | |
| 12 | +export default async function ProviderOgImage({ params }: { params: Promise<{ slug: string }> }) { | |
| 13 | + const { slug } = await params; | |
| 14 | + const res = await safe(intel.providers()); | |
| 15 | + const p = res?.items.find((x) => x.slug === slug); | |
| 16 | + if (!p) return new ImageResponse(<Fallback label="Provider" />, { ...size }); | |
| 17 | + const counters: [string, string][] = [ | |
| 18 | + ['Models served', fmtInt(p.model_count)], | |
| 19 | + ['Organizations', fmtInt(p.organizations_covered)], | |
| 20 | + ['Median input / 1M', fmtUsdPerM(p.input_price_distribution?.median)], | |
| 21 | + ['Median output / 1M', fmtUsdPerM(p.output_price_distribution?.median)], | |
| 22 | + ]; | |
| 23 | + return new ImageResponse(<Wallpaper eyebrow={p.organization ? `Provider · ${p.organization.name}` : 'Provider'} title={p.name} subtitle="Models served, published prices per 1M tokens, distributions, listings and price history — every row with its source." counters={counters} footer={`www.ai-atlas.co/providers/${slug}`} markPx={220} />, { ...size }); | |
| 24 | +} | |
added
apps/web/src/app/providers/[slug]/page.tsx
+275 −0
@@ -0,0 +1,275 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import type { Metadata } from 'next'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { permanentRedirect } from 'next/navigation'; | |
| 5 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 6 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 7 | +import { RelationsBlock, SourcesTable, TimelineList } from '@/components/entity/blocks'; | |
| 8 | +import { buildMetadata, loadEntity } from '@/components/entity/load'; | |
| 9 | +import { Methodology } from '@/components/intelligence/bits'; | |
| 10 | +import { ExpandableOfferRow } from '@/components/intelligence/expand-price-row'; | |
| 11 | +import { ViewBeacon } from '@/components/layout/view-beacon'; | |
| 12 | +import { PriceMovers } from '@/components/prices/movers'; | |
| 13 | +import { ProviderStrip } from '@/components/providers/provider-strip'; | |
| 14 | +import { Chip, EntityBadge, StatusBadge } from '@/components/ui/badges'; | |
| 15 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 16 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 17 | +import { KeyValue } from '@/components/ui/key-value'; | |
| 18 | +import { SourceCell } from '@/components/ui/provenance'; | |
| 19 | +import { Container, Note, Section } from '@/components/ui/section'; | |
| 20 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 21 | +import { WatchButton } from '@/components/watchlist/watch-button'; | |
| 22 | +import { api, intel, safe } from '@/lib/api'; | |
| 23 | +import { fmtAgo, fmtDate, fmtInt, fmtTokens, fmtUsdPerM, num } from '@/lib/format'; | |
| 24 | +import { PROSE_KEYS, routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 25 | +import type { Deployment } from '@/lib/types'; | |
| 26 | + | |
| 27 | +export const revalidate = 300; | |
| 28 | +type Params = { params: Promise<{ slug: string }> }; | |
| 29 | +const FEATURE_LABELS: Record<string, string> = { batch: 'Batch', cached: 'Prompt caching', fine_tuning: 'Fine-tuning', audio: 'Audio', image: 'Image', video: 'Video', web_search: 'Web search', flex: 'Flex tier', long_context: 'Long-context tier', priority: 'Priority tier' }; | |
| 30 | + | |
| 31 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 32 | + const { slug } = await params; | |
| 33 | + const d = await safe(api.entityOfType('providers', slug)); | |
| 34 | + if (!d || d.entity_type !== 'provider') return { title: 'Provider', robots: { index: false } }; | |
| 35 | + const m = buildMetadata(d); | |
| 36 | + return { ...m, title: `${d.name} — models served, prices per 1M tokens, price history`, description: `${d.name}${d.organization ? ` (${d.organization.name})` : ''}: every model it currently serves with published input, output, cached and batch prices per 1M tokens, context windows, price distributions, listings, delistings and price change events — each with its source. ${SITE_NAME}.` }; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export default async function ProviderPage({ params }: Params) { | |
| 40 | + const { slug } = await params; | |
| 41 | + const d = await loadEntity('providers', slug); | |
| 42 | + const canonical = routes.entity(d); | |
| 43 | + if (canonical !== `/providers/${encodeURIComponent(slug)}`) permanentRedirect(canonical); | |
| 44 | + const since = Date.now() - 30 * 86400000; | |
| 45 | + const [providers, current, closed, priceEvents] = await Promise.all([safe(intel.providers()), safe(intel.deployments({ provider: d.slug, limit: 200, sort: 'model' })), safe(intel.deployments({ provider: d.slug, current: 0, limit: 200, sort: 'valid_from' })), safe(api.changes({ type: 'PRICE_CHANGED', limit: 200 }))]); | |
| 46 | + const row = providers?.items.find((p) => p.slug === d.slug) ?? null; | |
| 47 | + const deployments: Deployment[] = current?.items ?? []; | |
| 48 | + const added = deployments.filter((x) => new Date(x.valid_from).getTime() >= since).sort((a, b) => b.valid_from.localeCompare(a.valid_from)); | |
| 49 | + const removed = (closed?.items ?? []).filter((x) => x.status === 'delisted' || x.valid_to).sort((a, b) => (b.valid_to ?? '').localeCompare(a.valid_to ?? '')); | |
| 50 | + const events = (priceEvents?.items ?? []).filter((e) => e.meta?.provider_id === d.id || e.meta?.provider === d.name); | |
| 51 | + const orgs = [...new Map(deployments.filter((x) => x.model.organization).map((x) => [x.model.organization!.slug, x.model.organization!])).values()].sort((a, b) => a.name.localeCompare(b.name)); | |
| 52 | + const featureKeys = row?.feature_keys ?? [...new Set(deployments.flatMap((x) => Object.keys(x.features ?? {}).concat(Object.keys(x.prices.native_units ?? {}))))].sort(); | |
| 53 | + const a = d.attributes ?? {}; | |
| 54 | + const specRows = Object.keys(a) | |
| 55 | + .filter((k) => !PROSE_KEYS.has(k)) | |
| 56 | + .map((k) => ({ key: k, raw: a[k] })); | |
| 57 | + const ld = { | |
| 58 | + '@context': 'https://schema.org', | |
| 59 | + '@type': 'Organization', | |
| 60 | + name: d.name, | |
| 61 | + url: typeof a.website === 'string' ? a.website : undefined, | |
| 62 | + description: d.description ?? undefined, | |
| 63 | + parentOrganization: d.organization ? { '@type': 'Organization', name: d.organization.name } : undefined, | |
| 64 | + mainEntityOfPage: `${SITE_URL}${canonical}`, | |
| 65 | + }; | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <Container wide> | |
| 69 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 70 | + <ViewBeacon path={canonical} /> | |
| 71 | + <header className="pb-5 pt-7 md:pt-10"> | |
| 72 | + <div className="flex flex-wrap items-center gap-2 text-sm text-ink-3"> | |
| 73 | + <Link href={routes.providers()} className="hover:text-ink">Providers</Link> <span aria-hidden>/</span> | |
| 74 | + <EntityBadge type={d.entity_type} /> | |
| 75 | + <StatusBadge status={d.status} /> | |
| 76 | + </div> | |
| 77 | + <div className="mt-2 flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> | |
| 78 | + <div className="min-w-0"> | |
| 79 | + <h1 className="display text-[28px] md:text-[40px]">{d.name}</h1> | |
| 80 | + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2"> | |
| 81 | + {d.organization && ( | |
| 82 | + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="hover:text-accent"> | |
| 83 | + Operated by {d.organization.name} | |
| 84 | + </Link> | |
| 85 | + )} | |
| 86 | + {typeof a.website === 'string' && ( | |
| 87 | + <a href={a.website} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:text-accent"> | |
| 88 | + {a.website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')} <ExternalLink className="size-3" aria-hidden /> | |
| 89 | + </a> | |
| 90 | + )} | |
| 91 | + {typeof a.pricing_url === 'string' && ( | |
| 92 | + <a href={a.pricing_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:text-accent"> | |
| 93 | + pricing page <ExternalLink className="size-3" aria-hidden /> | |
| 94 | + </a> | |
| 95 | + )} | |
| 96 | + <QualityMark q={d.quality?.score} label /> | |
| 97 | + </p> | |
| 98 | + {d.description && <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>} | |
| 99 | + </div> | |
| 100 | + <div className="flex shrink-0 flex-wrap items-center gap-2"> | |
| 101 | + <CompareButton e={d} /> | |
| 102 | + <WatchButton e={d} /> | |
| 103 | + <Link href={routes.graph(d.slug)} className="inline-flex h-9 items-center border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 104 | + Explore graph | |
| 105 | + </Link> | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + </header> | |
| 109 | + | |
| 110 | + {row ? <ProviderStrip p={row} note={providers?.note} /> : <Unavailable what="Provider aggregates" compact />} | |
| 111 | + | |
| 112 | + {/* ---------------------------------------------------------------------------------------- models served */} | |
| 113 | + <Section eyebrow="Models served" title={`${fmtInt(current?.total ?? deployments.length)} current deployments`} lede="One row per model × provider model id, as published. Expand a row to load its full price history as a step chart." action={{ href: `${routes.prices()}?provider=${encodeURIComponent(d.slug)}`, label: 'In the price terminal' }} hairline={false}> | |
| 114 | + {!current ? ( | |
| 115 | + <Unavailable what="Deployments" /> | |
| 116 | + ) : deployments.length === 0 ? ( | |
| 117 | + <EmptyState title="No current deployment recorded">The pricing page has not yielded a priced model row yet.</EmptyState> | |
| 118 | + ) : ( | |
| 119 | + <DataTable scroll compact> | |
| 120 | + <thead> | |
| 121 | + <tr> | |
| 122 | + <Th>Model</Th> | |
| 123 | + <Th>Provider model id</Th> | |
| 124 | + <Th num>Context</Th> | |
| 125 | + <Th num>Input / 1M</Th> | |
| 126 | + <Th num>Output / 1M</Th> | |
| 127 | + <Th num>Cached in</Th> | |
| 128 | + <Th num>Batch in / out</Th> | |
| 129 | + <Th>Status</Th> | |
| 130 | + <Th>Observed</Th> | |
| 131 | + <Th>Source</Th> | |
| 132 | + <Th className="w-24" aria-label="History" /> | |
| 133 | + </tr> | |
| 134 | + </thead> | |
| 135 | + <tbody> | |
| 136 | + {deployments.map((x) => ( | |
| 137 | + <ExpandableOfferRow key={x.id} model={x.model.slug} provider={d.slug} colSpan={10} mode="step"> | |
| 138 | + <Td primary> | |
| 139 | + <EntityLink e={x.model} /> | |
| 140 | + {x.model.organization && <span className="ml-2 text-xs text-ink-3">{x.model.organization.name}</span>} | |
| 141 | + </Td> | |
| 142 | + <Td label="Provider model id" className="mono text-[11px] text-ink-2">{x.provider_model_id ?? '—'}</Td> | |
| 143 | + <Td num label="Context" className="tnum text-ink-2">{fmtTokens(x.context_length)}</Td> | |
| 144 | + <Td num label="Input / 1M" className="tnum text-accent-2">{fmtUsdPerM(x.prices.input)}</Td> | |
| 145 | + <Td num label="Output / 1M" className="tnum text-accent-2">{fmtUsdPerM(x.prices.output)}</Td> | |
| 146 | + <Td num label="Cached in" className="tnum text-ink-2">{fmtUsdPerM(x.prices.cached_input)}</Td> | |
| 147 | + <Td num label="Batch" className="tnum text-ink-2">{num(x.prices.batch_input) === null && num(x.prices.batch_output) === null ? <span className="text-ink-3">—</span> : `${fmtUsdPerM(x.prices.batch_input)} / ${fmtUsdPerM(x.prices.batch_output)}`}</Td> | |
| 148 | + <Td label="Status"><StatusBadge status={x.status} /></Td> | |
| 149 | + <Td label="Observed" className="text-ink-2 whitespace-nowrap" title={x.observed_at}>{fmtAgo(x.observed_at)}</Td> | |
| 150 | + <Td label="Source"><SourceCell url={x.source_url} tier={x.tier} /></Td> | |
| 151 | + </ExpandableOfferRow> | |
| 152 | + ))} | |
| 153 | + {deployments.length === 0 && <EmptyRow cols={11} />} | |
| 154 | + </tbody> | |
| 155 | + </DataTable> | |
| 156 | + )} | |
| 157 | + {current && current.total > deployments.length && <Note className="mt-2">Showing the first {fmtInt(deployments.length)} of {fmtInt(current.total)} deployments — the rest are in the <Link href={`${routes.prices()}?provider=${encodeURIComponent(d.slug)}`} className="link">price terminal</Link>.</Note>} | |
| 158 | + </Section> | |
| 159 | + | |
| 160 | + {/* ----------------------------------------------------------------------------------- added / removed */} | |
| 161 | + <Section eyebrow="Listings · 30 days" title="Models added and removed"> | |
| 162 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-8 md:grid-cols-2"> | |
| 163 | + <div> | |
| 164 | + <p className="flex items-baseline justify-between text-sm"> | |
| 165 | + <span className="font-medium text-ink">Added</span> | |
| 166 | + <span className="tnum text-ink-3">{fmtInt(row?.models_added_30d ?? added.length)}</span> | |
| 167 | + </p> | |
| 168 | + {added.length ? ( | |
| 169 | + <ul className="mt-2 border-t border-rule"> | |
| 170 | + {added.slice(0, 12).map((x) => ( | |
| 171 | + <li key={x.id} className="flex flex-wrap items-baseline gap-x-3 border-b border-rule py-2 text-sm"> | |
| 172 | + <EntityLink e={x.model} /> | |
| 173 | + <span className="tnum ml-auto text-xs text-ink-3">{fmtDate(x.valid_from)}</span> | |
| 174 | + </li> | |
| 175 | + ))} | |
| 176 | + {added.length > 12 && <li className="py-2 text-xs text-ink-3">+{added.length - 12} more in the table above.</li>} | |
| 177 | + </ul> | |
| 178 | + ) : ( | |
| 179 | + <p className="mt-2 text-sm text-ink-3">No listing opened in the last 30 days.</p> | |
| 180 | + )} | |
| 181 | + </div> | |
| 182 | + <div> | |
| 183 | + <p className="flex items-baseline justify-between text-sm"> | |
| 184 | + <span className="font-medium text-ink">Removed</span> | |
| 185 | + <span className="tnum text-ink-3">{fmtInt(row?.models_removed_30d ?? removed.length)}</span> | |
| 186 | + </p> | |
| 187 | + {removed.length ? ( | |
| 188 | + <ul className="mt-2 border-t border-rule"> | |
| 189 | + {removed.slice(0, 12).map((x) => ( | |
| 190 | + <li key={x.id} className="flex flex-wrap items-baseline gap-x-3 border-b border-rule py-2 text-sm"> | |
| 191 | + <EntityLink e={x.model} /> | |
| 192 | + <Chip>{x.status}</Chip> | |
| 193 | + <span className="tnum ml-auto text-xs text-ink-3">{fmtDate(x.valid_to)}</span> | |
| 194 | + </li> | |
| 195 | + ))} | |
| 196 | + </ul> | |
| 197 | + ) : ( | |
| 198 | + <p className="mt-2 text-sm text-ink-3">No delisting recorded — a model that disappears from the pricing page is closed (valid_to) and listed here.</p> | |
| 199 | + )} | |
| 200 | + </div> | |
| 201 | + </div> | |
| 202 | + </Section> | |
| 203 | + | |
| 204 | + {/* ------------------------------------------------------------------------------------- price events */} | |
| 205 | + <Section eyebrow="Price events" title={`Price changes · ${fmtInt(row?.price_changes_30d ?? events.length)} in 30 days`} lede="PRICE_CHANGED events where this provider is the source of the price." action={{ href: `${routes.changes()}?type=PRICE_CHANGED`, label: 'All price events' }}> | |
| 206 | + {!priceEvents ? <Unavailable what="Price events" /> : <PriceMovers movers={events} limit={50} />} | |
| 207 | + </Section> | |
| 208 | + | |
| 209 | + {/* ---------------------------------------------------------------------------------- features & limits */} | |
| 210 | + <Section eyebrow="Features & limits" title="What this provider prices and publishes"> | |
| 211 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]"> | |
| 212 | + <div> | |
| 213 | + <p className="eyebrow mb-1.5">Priced features</p> | |
| 214 | + {row?.features_supported?.length ? ( | |
| 215 | + <ul className="flex flex-wrap gap-1.5"> | |
| 216 | + {row.features_supported.map((f) => ( | |
| 217 | + <li key={f}> | |
| 218 | + <Chip tone="accent">{FEATURE_LABELS[f] ?? f.replace(/_/g, ' ')}</Chip> | |
| 219 | + </li> | |
| 220 | + ))} | |
| 221 | + </ul> | |
| 222 | + ) : ( | |
| 223 | + <p className="text-sm text-ink-3">No priced feature beyond input/output tokens recorded.</p> | |
| 224 | + )} | |
| 225 | + {featureKeys.length > 0 && ( | |
| 226 | + <> | |
| 227 | + <p className="eyebrow mb-1.5 mt-4">Native price keys observed</p> | |
| 228 | + <ul className="mono flex flex-wrap gap-1 text-[11px] text-ink-2"> | |
| 229 | + {featureKeys.map((k) => ( | |
| 230 | + <li key={k} className="rounded-[3px] bg-surface-2 px-1.5 py-[1px]"> | |
| 231 | + {k} | |
| 232 | + </li> | |
| 233 | + ))} | |
| 234 | + </ul> | |
| 235 | + <Note className="mt-1.5">Provider-specific units (per 1K requests, per minute, per image…) are kept verbatim in each offer's native_units and are not converted.</Note> | |
| 236 | + </> | |
| 237 | + )} | |
| 238 | + <p className="eyebrow mb-1.5 mt-4">Organizations covered</p> | |
| 239 | + {orgs.length ? ( | |
| 240 | + <ul className="flex flex-wrap gap-1.5"> | |
| 241 | + {orgs.map((o) => ( | |
| 242 | + <li key={o.slug}> | |
| 243 | + <Link href={routes.entity({ entity_type: 'company', slug: o.slug })} className="inline-flex h-7 items-center border border-rule px-2 text-xs text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 244 | + {o.name} | |
| 245 | + </Link> | |
| 246 | + </li> | |
| 247 | + ))} | |
| 248 | + </ul> | |
| 249 | + ) : ( | |
| 250 | + <p className="text-sm text-ink-3">—</p> | |
| 251 | + )} | |
| 252 | + </div> | |
| 253 | + <div> | |
| 254 | + <p className="eyebrow mb-1.5">Published attributes</p> | |
| 255 | + <KeyValue rows={specRows} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} dense /> | |
| 256 | + </div> | |
| 257 | + </div> | |
| 258 | + <Methodology text={providers?.note} /> | |
| 259 | + </Section> | |
| 260 | + | |
| 261 | + {d.relations?.length ? ( | |
| 262 | + <Section eyebrow="Relations" title="In the graph" action={{ href: routes.graph(d.slug), label: 'Explore graph' }}> | |
| 263 | + <RelationsBlock relations={d.relations} exclude={['available_through']} /> | |
| 264 | + </Section> | |
| 265 | + ) : null} | |
| 266 | + <Section eyebrow="Timeline" title="Events" action={{ href: routes.timeline({ entity: d.slug }), label: 'Full timeline' }}> | |
| 267 | + <TimelineList events={d.timeline ?? []} slug={d.slug} /> | |
| 268 | + </Section> | |
| 269 | + <Section eyebrow="Sources" title="Where these facts come from"> | |
| 270 | + <SourcesTable sources={d.sources ?? []} /> | |
| 271 | + </Section> | |
| 272 | + <CompareTrayBar /> | |
| 273 | + </Container> | |
| 274 | + ); | |
| 275 | +} | |
modified
apps/web/src/app/providers/page.tsx
+149 −36
@@ -2,71 +2,184 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { CompareButton } from '@/components/compare/compare-button'; |
| 4 | 4 | import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; |
| 5 | +import { BTN_GHOST, CTRL, Field, Methodology, RangeBar, SortTh, distDomain } from '@/components/intelligence/bits'; | |
| 6 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 7 | +import { Chip } from '@/components/ui/badges'; | |
| 5 | 8 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 6 | 9 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
| 7 | −import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 8 | −import { Unavailable } from '@/components/ui/unavailable'; | |
| 9 | −import { api, safe } from '@/lib/api'; | |
| 10 | −import { fmtAgo, fmtInt, fmtUsdPerM, num } from '@/lib/format'; | |
| 10 | +import { withParams } from '@/components/ui/pagination'; | |
| 11 | +import { Note, PageHeader } from '@/components/ui/section'; | |
| 12 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 13 | +import { intel, safe } from '@/lib/api'; | |
| 14 | +import { fmtAgo, fmtInt, fmtSigned, fmtUsdPerM, num } from '@/lib/format'; | |
| 15 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 16 | +import type { ProviderIntelRow } from '@/lib/types'; | |
| 11 | 17 | |
| 12 | −export const metadata: Metadata = { title: 'AI providers & pricing — USD per 1M tokens', description: 'Inference providers and their published prices per 1M tokens, with model coverage and a full price history.', alternates: { canonical: '/providers' } }; | |
| 13 | 18 | export const revalidate = 300; |
| 19 | +type SP = Record<string, string | undefined>; | |
| 20 | +const SORTS = ['models', 'orgs', 'median_input', 'median_output', 'changes', 'added', 'name', 'updated'] as const; | |
| 21 | +type Sort = (typeof SORTS)[number]; | |
| 22 | +const SORT_LABELS: Record<Sort, string> = { models: 'Models served', orgs: 'Organizations covered', median_input: 'Median input price', median_output: 'Median output price', changes: 'Price changes · 30 d', added: 'Models added · 30 d', name: 'Name', updated: 'Recently updated' }; | |
| 23 | +const KEYS = ['sort', 'feature', 'min_models', 'q'] as const; | |
| 24 | +const FEATURE_LABELS: Record<string, string> = { batch: 'Batch', cached: 'Prompt caching', fine_tuning: 'Fine-tuning', audio: 'Audio', image: 'Image', video: 'Video', web_search: 'Web search', flex: 'Flex tier', long_context: 'Long-context tier', priority: 'Priority tier', reasoning: 'Reasoning tokens' }; | |
| 25 | + | |
| 26 | +const TITLE = 'AI inference providers — prices, coverage and changes'; | |
| 27 | +const DESC = 'Every inference provider the atlas tracks: models served, organizations covered, input and output price distributions (min · p25 · median · p75 · max, USD per 1M tokens), price changes and models added or removed in the last 30 days, and the features each provider prices (batch, caching, fine-tuning…).'; | |
| 28 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 29 | + const sp = await searchParams; | |
| 30 | + const filtered = KEYS.some((k) => sp[k] && k !== 'sort'); | |
| 31 | + return { title: TITLE, description: DESC, alternates: { canonical: routes.providers() }, openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.providers()}`, siteName: SITE_NAME }, robots: filtered ? { index: false, follow: true } : undefined }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +const med = (d: ProviderIntelRow['input_price_distribution']) => num(d?.median); | |
| 35 | + | |
| 36 | +export default async function ProvidersPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 37 | + const sp = await searchParams; | |
| 38 | + const cur: Record<string, string | undefined> = {}; | |
| 39 | + for (const k of KEYS) if (sp[k]) cur[k] = sp[k]; | |
| 40 | + const sort: Sort = (SORTS as readonly string[]).includes(cur.sort ?? '') ? (cur.sort as Sort) : 'models'; | |
| 41 | + const res = await safe(intel.providers()); | |
| 42 | + const all = res?.items ?? []; | |
| 43 | + const features = [...new Set(all.flatMap((p) => p.features_supported ?? []))].sort(); | |
| 44 | + const minModels = num(cur.min_models); | |
| 45 | + const q = cur.q?.trim().toLowerCase(); | |
| 46 | + let items = all.filter((p) => (!cur.feature || (p.features_supported ?? []).includes(cur.feature)) && (minModels === null || (num(p.model_count) ?? 0) >= minModels) && (!q || p.name.toLowerCase().includes(q) || p.organization?.name.toLowerCase().includes(q))); | |
| 47 | + const by = (f: (p: ProviderIntelRow) => number | null, dir: 1 | -1 = -1) => (a: ProviderIntelRow, b: ProviderIntelRow) => { | |
| 48 | + const x = f(a); | |
| 49 | + const y = f(b); | |
| 50 | + if (x === null && y === null) return 0; | |
| 51 | + if (x === null) return 1; | |
| 52 | + if (y === null) return -1; | |
| 53 | + return (x - y) * dir; | |
| 54 | + }; | |
| 55 | + items = items.slice().sort( | |
| 56 | + sort === 'name' ? (a, b) => a.name.localeCompare(b.name) : sort === 'updated' ? (a, b) => b.updated_at.localeCompare(a.updated_at) : sort === 'orgs' ? by((p) => num(p.organizations_covered)) : sort === 'median_input' ? by((p) => med(p.input_price_distribution), 1) : sort === 'median_output' ? by((p) => med(p.output_price_distribution), 1) : sort === 'changes' ? by((p) => num(p.price_changes_30d)) : sort === 'added' ? by((p) => num(p.models_added_30d)) : by((p) => num(p.model_count)), | |
| 57 | + ); | |
| 58 | + const domainIn = distDomain(all.map((p) => p.input_price_distribution)); | |
| 59 | + const domainOut = distDomain(all.map((p) => p.output_price_distribution)); | |
| 60 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/providers', cur, patch); | |
| 61 | + const filterCount = ['feature', 'min_models', 'q'].filter((k) => cur[k]).length; | |
| 62 | + | |
| 63 | + const filters = ( | |
| 64 | + <form action="/providers" method="get" className="space-y-3" data-provider-filters> | |
| 65 | + <Field label="Name"> | |
| 66 | + <input name="q" defaultValue={cur.q ?? ''} placeholder="Provider or organization" className={CTRL} /> | |
| 67 | + </Field> | |
| 68 | + <Field label="Priced feature"> | |
| 69 | + <select name="feature" defaultValue={cur.feature ?? ''} className={CTRL}> | |
| 70 | + <option value="">Any</option> | |
| 71 | + {features.map((f) => ( | |
| 72 | + <option key={f} value={f}> | |
| 73 | + {FEATURE_LABELS[f] ?? f.replace(/_/g, ' ')} | |
| 74 | + </option> | |
| 75 | + ))} | |
| 76 | + </select> | |
| 77 | + </Field> | |
| 78 | + <Field label="Models served ≥"> | |
| 79 | + <input name="min_models" inputMode="numeric" defaultValue={cur.min_models ?? ''} placeholder="e.g. 20" className={CTRL} /> | |
| 80 | + </Field> | |
| 81 | + <Field label="Sort"> | |
| 82 | + <select name="sort" defaultValue={sort} className={CTRL}> | |
| 83 | + {SORTS.map((s) => ( | |
| 84 | + <option key={s} value={s}> | |
| 85 | + {SORT_LABELS[s]} | |
| 86 | + </option> | |
| 87 | + ))} | |
| 88 | + </select> | |
| 89 | + </Field> | |
| 90 | + <div className="flex gap-2"> | |
| 91 | + <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink lg:h-10 px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 92 | + Apply | |
| 93 | + </button> | |
| 94 | + <Link href="/providers" className={BTN_GHOST}> | |
| 95 | + Reset | |
| 96 | + </Link> | |
| 97 | + </div> | |
| 98 | + </form> | |
| 99 | + ); | |
| 100 | + | |
| 101 | + const inspector = ( | |
| 102 | + <div className="space-y-3 text-xs leading-relaxed text-ink-2"> | |
| 103 | + <p>{res?.note ?? 'Aggregates unavailable.'}</p> | |
| 104 | + <p> | |
| 105 | + Range bars share one logarithmic axis per column (input {fmtUsdPerM(domainIn[0])} → {fmtUsdPerM(domainIn[1])}; output {fmtUsdPerM(domainOut[0])} → {fmtUsdPerM(domainOut[1])}): the light band spans min → max, the solid band p25 → p75, the tick is the median. | |
| 106 | + </p> | |
| 107 | + <p> | |
| 108 | + Features = keys priced on at least one offer (batch, cached input, fine-tuning, audio…), as published. <Link href="/developers" className="link">GET /providers</Link> | |
| 109 | + </p> | |
| 110 | + </div> | |
| 111 | + ); | |
| 14 | 112 | |
| 15 | −export default async function ProvidersPage() { | |
| 16 | − const res = await safe(api.providers()); | |
| 17 | − const items = (res?.items ?? []).slice().sort((a, b) => (num(b.model_count) ?? 0) - (num(a.model_count) ?? 0)); | |
| 18 | 113 | return ( |
| 19 | − <Container> | |
| 20 | − <PageHeader eyebrow="Providers & pricing" title="Inference providers" lede="Who serves which model, at what published price. Prices are per 1M tokens as stated on each provider's pricing page; every change is kept." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} providers</p> : undefined}> | |
| 21 | − <p className="mt-4 text-sm text-ink-2"> | |
| 22 | − Per-model prices and history live on each <Link href="/models" className="link">model page</Link> (Providers & Pricing tab) and on each provider page. | |
| 23 | − </p> | |
| 24 | − </PageHeader> | |
| 114 | + <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Reading" storageKey="aia-inspector-providers" filterCount={filterCount}> | |
| 115 | + <PageHeader eyebrow="Providers & pricing" title="Inference providers" lede="Who serves which models, at what published prices, and how those prices move. Distributions are over live offers with a positive price; every change is kept." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} of {fmtInt(all.length)} providers</p> : undefined} className="pt-4 md:pt-6" /> | |
| 25 | 116 | <div className="pb-16"> |
| 26 | 117 | {!res ? ( |
| 27 | 118 | <Unavailable what="Providers" /> |
| 119 | + ) : items.length === 0 ? ( | |
| 120 | + <EmptyState title="No provider matches these filters">Remove the feature or model-count filter.</EmptyState> | |
| 28 | 121 | ) : ( |
| 29 | 122 | <> |
| 30 | − <DataTable caption="Providers"> | |
| 123 | + <DataTable scroll compact> | |
| 31 | 124 | <thead> |
| 32 | 125 | <tr> |
| 33 | − <Th>Provider</Th> | |
| 34 | − <Th num>Models</Th> | |
| 35 | − <Th num>Price rows</Th> | |
| 36 | − <Th num>Cheapest input / 1M</Th> | |
| 37 | − <Th num>Cheapest output / 1M</Th> | |
| 38 | − <Th>Updated</Th> | |
| 126 | + <SortTh active={sort === 'name'} href={href({ sort: 'name' })}>Provider</SortTh> | |
| 127 | + <SortTh active={sort === 'models'} href={href({ sort: undefined })} num dir="desc">Models</SortTh> | |
| 128 | + <SortTh active={sort === 'orgs'} href={href({ sort: 'orgs' })} num dir="desc">Orgs</SortTh> | |
| 129 | + <SortTh active={sort === 'median_input'} href={href({ sort: 'median_input' })}>Input / 1M · distribution</SortTh> | |
| 130 | + <SortTh active={sort === 'median_output'} href={href({ sort: 'median_output' })}>Output / 1M · distribution</SortTh> | |
| 131 | + <SortTh active={sort === 'changes'} href={href({ sort: 'changes' })} num dir="desc">Δ price · 30 d</SortTh> | |
| 132 | + <SortTh active={sort === 'added'} href={href({ sort: 'added' })} num dir="desc">± models · 30 d</SortTh> | |
| 133 | + <Th>Features</Th> | |
| 134 | + <SortTh active={sort === 'updated'} href={href({ sort: 'updated' })} dir="desc">Updated</SortTh> | |
| 39 | 135 | <Th num>Quality</Th> |
| 136 | + <Th className="w-24" aria-label="Compare" /> | |
| 40 | 137 | </tr> |
| 41 | 138 | </thead> |
| 42 | 139 | <tbody> |
| 43 | − {items.length === 0 && <EmptyRow cols={7}>No providers recorded yet.</EmptyRow>} | |
| 140 | + {items.length === 0 && <EmptyRow cols={11} />} | |
| 44 | 141 | {items.map((p) => ( |
| 45 | − <tr key={p.id}> | |
| 142 | + <tr key={p.id} data-provider-row> | |
| 46 | 143 | <Td primary> |
| 47 | − <div className="flex items-start justify-between gap-2"> | |
| 48 | − <div className="min-w-0"> | |
| 49 | − <EntityLink e={p} /> | |
| 50 | − {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>} | |
| 51 | − </div> | |
| 52 | − <CompareButton e={p} size="sm" className="mt-0.5" /> | |
| 53 | − </div> | |
| 144 | + <EntityLink e={p} /> | |
| 145 | + {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>} | |
| 54 | 146 | </Td> |
| 55 | 147 | <Td num label="Models" className="tnum">{fmtInt(p.model_count)}</Td> |
| 56 | − <Td num label="Price rows" className="tnum text-ink-2">{fmtInt(p.price_count)}</Td> | |
| 57 | − <Td num label="Cheapest input" className="tnum text-accent-2">{fmtUsdPerM(p.min_input_per_mtok)}</Td> | |
| 58 | − <Td num label="Cheapest output" className="tnum text-accent-2">{fmtUsdPerM(p.min_output_per_mtok)}</Td> | |
| 59 | − <Td label="Updated" className="text-ink-2" title={p.updated_at}>{fmtAgo(p.updated_at)}</Td> | |
| 148 | + <Td num label="Organizations" className="tnum text-ink-2">{fmtInt(p.organizations_covered)}</Td> | |
| 149 | + <Td label="Input distribution"> | |
| 150 | + <RangeBar d={p.input_price_distribution} domain={domainIn} label="Input price" /> | |
| 151 | + </Td> | |
| 152 | + <Td label="Output distribution"> | |
| 153 | + <RangeBar d={p.output_price_distribution} domain={domainOut} label="Output price" /> | |
| 154 | + </Td> | |
| 155 | + <Td num label="Price changes 30 d" className="tnum">{fmtInt(p.price_changes_30d)}</Td> | |
| 156 | + <Td num label="Models ± 30 d" className="tnum"> | |
| 157 | + <span className="text-positive">{fmtSigned(p.models_added_30d)}</span> <span className="text-ink-3">/</span> <span className={num(p.models_removed_30d) ? 'text-danger' : 'text-ink-3'}>{num(p.models_removed_30d) ? `−${fmtInt(p.models_removed_30d)}` : '0'}</span> | |
| 158 | + </Td> | |
| 159 | + <Td label="Features" className="max-w-[16rem]"> | |
| 160 | + <span className="flex flex-wrap gap-1"> | |
| 161 | + {(p.features_supported ?? []).slice(0, 6).map((f) => ( | |
| 162 | + <Chip key={f}>{FEATURE_LABELS[f] ?? f.replace(/_/g, ' ')}</Chip> | |
| 163 | + ))} | |
| 164 | + {(p.features_supported?.length ?? 0) > 6 && <span className="text-[11px] text-ink-3">+{(p.features_supported?.length ?? 0) - 6}</span>} | |
| 165 | + {!(p.features_supported?.length ?? 0) && <span className="text-xs text-ink-3">—</span>} | |
| 166 | + </span> | |
| 167 | + </Td> | |
| 168 | + <Td label="Updated" className="text-ink-2 whitespace-nowrap" title={p.updated_at}>{fmtAgo(p.updated_at)}</Td> | |
| 60 | 169 | <Td num label="Quality"><QualityMark q={p.quality?.score} /></Td> |
| 170 | + <Td className="text-right"> | |
| 171 | + <CompareButton e={p} size="sm" /> | |
| 172 | + </Td> | |
| 61 | 173 | </tr> |
| 62 | 174 | ))} |
| 63 | 175 | </tbody> |
| 64 | 176 | </DataTable> |
| 65 | − <Note className="mt-3">Cheapest = lowest current input/output price across the models this provider serves. Open each provider for the full table and history.</Note> | |
| 177 | + <Note className="mt-3">Open a provider for its models-served table with price history, listings and delistings, price events and priced features. Prices are USD per 1M tokens as published.</Note> | |
| 178 | + <Methodology text={res.note} /> | |
| 66 | 179 | </> |
| 67 | 180 | )} |
| 68 | 181 | </div> |
| 69 | 182 | <CompareTrayBar /> |
| 70 | − </Container> | |
| 183 | + </TerminalLayout> | |
| 71 | 184 | ); |
| 72 | 185 | } |
added
apps/web/src/app/pulse/opengraph-image.tsx
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { Wallpaper } from '@/components/brand/og'; | |
| 3 | +import { intel, safe } from '@/lib/api'; | |
| 4 | +import { fmtInt } from '@/lib/format'; | |
| 5 | +import { SITE_NAME } from '@/lib/site'; | |
| 6 | + | |
| 7 | +export const runtime = 'nodejs'; | |
| 8 | +export const alt = `Ecosystem Pulse on ${SITE_NAME}`; | |
| 9 | +export const size = { width: 1200, height: 630 }; | |
| 10 | +export const contentType = 'image/png'; | |
| 11 | + | |
| 12 | +export default async function PulseOgImage() { | |
| 13 | + const p = await safe(intel.pulse(7)); | |
| 14 | + const c = p?.counters ?? {}; | |
| 15 | + const counters: [string, string][] = p ? [['New models · 7 d', fmtInt(c.new_models?.value)], ['Open-weight', fmtInt(c.new_open_weight_models?.value)], ['Price changes', fmtInt(c.price_changes?.value)], ['New leaders', fmtInt(c.new_benchmark_leaders?.value)]] : []; | |
| 16 | + return new ImageResponse(<Wallpaper eyebrow="Ecosystem pulse" title="The last 7 days in AI" subtitle="Deterministic counters over what occurred in the window — new models, papers, listings, price changes, benchmark leaders — backfill excluded." counters={counters} footer="www.ai-atlas.co/pulse" markPx={240} />, { ...size }); | |
| 17 | +} | |
added
apps/web/src/app/pulse/page.tsx
+250 −0
@@ -0,0 +1,250 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Sparkline } from '@/components/charts'; | |
| 4 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 5 | +import { Methodology, TrustChip } from '@/components/intelligence/bits'; | |
| 6 | +import { DataStrip, type StripItem } from '@/components/layout/terminal'; | |
| 7 | +import { PriceMovers } from '@/components/prices/movers'; | |
| 8 | +import { ChipRow } from '@/components/timeline/chip-row'; | |
| 9 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 10 | +import { EntityLink } from '@/components/ui/entity'; | |
| 11 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 12 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 13 | +import { api, intel, safe } from '@/lib/api'; | |
| 14 | +import { fmtDate, fmtDateTime, fmtInt, fmtScore, fmtTokens, num } from '@/lib/format'; | |
| 15 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 16 | +import type { ChangeEvent, EntitySummary, PulseLeaderItem } from '@/lib/types'; | |
| 17 | + | |
| 18 | +export const revalidate = 120; | |
| 19 | + | |
| 20 | +const WINDOWS = [7, 30, 90]; | |
| 21 | +type SP = Record<string, string | undefined>; | |
| 22 | +const pickDays = (sp: SP) => (WINDOWS.includes(Number(sp.days)) ? Number(sp.days) : 7); | |
| 23 | + | |
| 24 | +const LABELS: Record<string, string> = { | |
| 25 | + new_models: 'New models', | |
| 26 | + new_open_weight_models: 'New open-weight models', | |
| 27 | + new_artifacts: 'New artifacts', | |
| 28 | + new_papers: 'New papers', | |
| 29 | + provider_listings: 'Provider listings', | |
| 30 | + provider_delistings: 'Delistings', | |
| 31 | + price_changes: 'Price changes', | |
| 32 | + new_models_1m_context: 'New ≥ 1M-context models', | |
| 33 | + new_benchmark_leaders: 'New benchmark leaders', | |
| 34 | + documents_changed: 'Documents changed', | |
| 35 | + sources_observed: 'Sources observed', | |
| 36 | + events_total: 'Events total', | |
| 37 | +}; | |
| 38 | +const HREF: Record<string, (days: number) => string> = { | |
| 39 | + new_models: () => `${routes.changes()}?type=NEW_MODEL`, | |
| 40 | + new_open_weight_models: () => `${routes.open()}`, | |
| 41 | + new_papers: () => `${routes.papers()}?sort=published`, | |
| 42 | + provider_listings: () => `${routes.changes()}?type=PROVIDER_LISTED`, | |
| 43 | + provider_delistings: () => `${routes.changes()}?type=PROVIDER_DELISTED`, | |
| 44 | + price_changes: () => `${routes.prices()}`, | |
| 45 | + new_benchmark_leaders: () => routes.benchmarks(), | |
| 46 | + documents_changed: () => `${routes.changes()}?type=DOCUMENT_CHANGED&include_documents=1`, | |
| 47 | + sources_observed: () => routes.sources(), | |
| 48 | + events_total: () => routes.changes(), | |
| 49 | +}; | |
| 50 | +/** stats/history series key per counter (only where the history payload carries one). */ | |
| 51 | +const HISTORY_KEY: Record<string, (c: Record<string, unknown>) => number | null> = { | |
| 52 | + new_models: (c) => num((c.entities as Record<string, unknown> | undefined)?.model), | |
| 53 | + new_papers: (c) => num((c.entities as Record<string, unknown> | undefined)?.paper), | |
| 54 | + price_changes: (c) => num(c.prices_current), | |
| 55 | + events_total: (c) => num(c.change_events), | |
| 56 | + documents_changed: (c) => num(c.documents), | |
| 57 | + sources_observed: (c) => num(c.sources), | |
| 58 | +}; | |
| 59 | + | |
| 60 | +const TITLE = 'Ecosystem Pulse — what happened in AI this week'; | |
| 61 | +const DESC = 'Deterministic counters over events that occurred in the window (7, 30 or 90 days) and are not back-filled history: new models, open-weight releases, artifacts, papers, provider listings and delistings, price changes, new 1M-context models, new benchmark leaders, documents and sources observed — each with its definition.'; | |
| 62 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 63 | + const days = pickDays(await searchParams); | |
| 64 | + const title = days === 7 ? TITLE : `Ecosystem Pulse — the last ${days} days in AI`; | |
| 65 | + return { title, description: DESC, alternates: { canonical: routes.pulse() }, openGraph: { title: `${title} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.pulse()}`, type: 'website', siteName: SITE_NAME }, twitter: { card: 'summary_large_image', title, description: DESC }, robots: days !== 7 ? { index: false, follow: true } : undefined }; | |
| 66 | +} | |
| 67 | + | |
| 68 | +const isLeader = (x: unknown): x is PulseLeaderItem => !!x && typeof x === 'object' && 'benchmark' in (x as object); | |
| 69 | +const isEvent = (x: unknown): x is ChangeEvent => !!x && typeof x === 'object' && 'event_type' in (x as object); | |
| 70 | +const isModel = (x: unknown): x is EntitySummary => !!x && typeof x === 'object' && 'entity_type' in (x as object) && 'slug' in (x as object); | |
| 71 | + | |
| 72 | +export default async function PulsePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 73 | + const days = pickDays(await searchParams); | |
| 74 | + const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); | |
| 75 | + const [pulse, history, priceFallback, ctxFallback] = await Promise.all([safe(intel.pulse(days)), safe(api.statsHistory(Math.max(days, 14))), safe(api.changes({ type: 'PRICE_CHANGED', since, limit: 30 })), safe(api.models({ min_context: 1_000_000, sort: 'release', limit: 12 }))]); | |
| 76 | + const href = (d: number) => (d === 7 ? routes.pulse() : `${routes.pulse()}?days=${d}`); | |
| 77 | + | |
| 78 | + if (!pulse) { | |
| 79 | + return ( | |
| 80 | + <Container wide> | |
| 81 | + <PageHeader eyebrow="Ecosystem pulse" title="Pulse" lede="Deterministic counters over what occurred in the window." /> | |
| 82 | + <Unavailable what="Pulse" reason="GET /pulse did not answer. Counters are never cached or hardcoded." /> | |
| 83 | + </Container> | |
| 84 | + ); | |
| 85 | + } | |
| 86 | + const counters = pulse.counters ?? {}; | |
| 87 | + const hist = history?.items ?? []; | |
| 88 | + const spark = (key: string): number[] => { | |
| 89 | + const f = HISTORY_KEY[key]; | |
| 90 | + if (!f) return []; | |
| 91 | + return hist.map((h) => f(h.counts as Record<string, unknown>)).filter((v): v is number => v !== null); | |
| 92 | + }; | |
| 93 | + const strip: StripItem[] = Object.entries(counters).map(([key, c]) => { | |
| 94 | + const vals = spark(key); | |
| 95 | + return { | |
| 96 | + label: LABELS[key] ?? key.replace(/_/g, ' '), | |
| 97 | + value: fmtInt(c.value), | |
| 98 | + definition: c.definition, | |
| 99 | + href: HREF[key]?.(days), | |
| 100 | + hint: vals.length >= 2 ? <Sparkline values={vals} width={64} height={16} stroke="var(--accent)" title={`${LABELS[key] ?? key} · daily stats history`} /> : num(c.median_percent) !== null ? `median ${fmtScore(c.median_percent)} %` : undefined, | |
| 101 | + }; | |
| 102 | + }); | |
| 103 | + const leaders = (counters.new_benchmark_leaders?.items ?? []).filter(isLeader); | |
| 104 | + const priceItems = (counters.price_changes?.items ?? []).filter(isEvent); | |
| 105 | + const priceRows = priceItems.length ? priceItems : (priceFallback?.items ?? []); | |
| 106 | + const ctxItems = (counters.new_models_1m_context?.items ?? []); | |
| 107 | + const ctxModels: EntitySummary[] = ctxItems.filter(isModel); | |
| 108 | + const ctxEvents: ChangeEvent[] = ctxItems.filter(isEvent); | |
| 109 | + const historyDays = hist.length; | |
| 110 | + const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: `AI Atlas Pulse · ${days} days`, description: DESC, url: `${SITE_URL}${routes.pulse()}`, temporalCoverage: `${pulse.since}/${pulse.until}`, creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL } }; | |
| 111 | + | |
| 112 | + return ( | |
| 113 | + <Container wide> | |
| 114 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 115 | + <PageHeader eyebrow="Ecosystem pulse" title={`The last ${days} days in AI`} lede="Deterministic counters over events that occurred in the window — historical backfill excluded, so a re-crawl of old pages never inflates them. Hover a label for how each one is counted." aside={<p className="tnum text-sm text-ink-3" title={`${pulse.since} → ${pulse.until}`}>{fmtDate(pulse.since)} → {fmtDateTime(pulse.until)}</p>}> | |
| 116 | + <div className="mt-6"> | |
| 117 | + <p className="eyebrow mb-1.5">Window</p> | |
| 118 | + <ChipRow label="Window" items={WINDOWS.map((d) => ({ href: href(d), label: `${d} days`, active: days === d }))} /> | |
| 119 | + </div> | |
| 120 | + </PageHeader> | |
| 121 | + | |
| 122 | + <DataStrip items={strip} /> | |
| 123 | + <p className="tnum mt-2 text-xs text-ink-3"> | |
| 124 | + All counters use <span className="mono">is_backfill = false</span> and <span className="mono">occurred_at</span> inside the window. Sparklines show the daily stats history where a series exists ({historyDays} day{historyDays === 1 ? '' : 's'} recorded so far{historyDays < 2 ? ' — a line needs two' : ''}). | |
| 125 | + </p> | |
| 126 | + <Methodology text={pulse.note} className="mt-1" /> | |
| 127 | + | |
| 128 | + {/* --------------------------------------------------------------------------------------------- leaders */} | |
| 129 | + <Section eyebrow="Benchmark leadership" title={`New benchmark leaders · ${fmtInt(counters.new_benchmark_leaders?.value)}`} lede={counters.new_benchmark_leaders?.definition} action={{ href: routes.benchmarks(), label: 'All leaderboards' }}> | |
| 130 | + {leaders.length === 0 ? ( | |
| 131 | + <EmptyState title="No leadership change in the window">The primary-group leader of every benchmark is unchanged over these {days} days.</EmptyState> | |
| 132 | + ) : ( | |
| 133 | + <div className="md:overflow-x-auto"> | |
| 134 | + <DataTable caption="New benchmark leaders"> | |
| 135 | + <thead> | |
| 136 | + <tr> | |
| 137 | + <Th>Benchmark</Th> | |
| 138 | + <Th>Current leader</Th> | |
| 139 | + <Th num>Score</Th> | |
| 140 | + <Th>Previous leader</Th> | |
| 141 | + <Th>Group</Th> | |
| 142 | + <Th>Trust</Th> | |
| 143 | + </tr> | |
| 144 | + </thead> | |
| 145 | + <tbody> | |
| 146 | + {leaders.map((l) => ( | |
| 147 | + <tr key={l.benchmark.id}> | |
| 148 | + <Td primary> | |
| 149 | + <Link href={routes.benchmark(l.benchmark.slug)} className="text-ink hover:text-accent hover:underline"> | |
| 150 | + {l.benchmark.name} | |
| 151 | + </Link> | |
| 152 | + </Td> | |
| 153 | + <Td label="Current leader"> | |
| 154 | + {l.current ? ( | |
| 155 | + <> | |
| 156 | + <Link href={routes.entity({ entity_type: l.current.model.entity_type ?? 'model', slug: l.current.model.slug })} className="font-medium text-ink hover:text-accent hover:underline"> | |
| 157 | + {l.current.model.name} | |
| 158 | + </Link> | |
| 159 | + {l.current.model.organization && <span className="ml-1.5 text-xs text-ink-3">{l.current.model.organization.name}</span>} | |
| 160 | + </> | |
| 161 | + ) : ( | |
| 162 | + '—' | |
| 163 | + )} | |
| 164 | + </Td> | |
| 165 | + <Td num label="Score" className="tnum font-medium">{l.current ? `${fmtScore(l.current.score)}${l.current.metric ? ` ${l.current.metric}` : ''}` : '—'}</Td> | |
| 166 | + <Td label="Previous leader" className="text-ink-2"> | |
| 167 | + {l.previous ? ( | |
| 168 | + <> | |
| 169 | + <Link href={routes.entity({ entity_type: l.previous.model.entity_type ?? 'model', slug: l.previous.model.slug })} className="hover:text-accent"> | |
| 170 | + {l.previous.model.name} | |
| 171 | + </Link>{' '} | |
| 172 | + <span className="tnum text-xs text-ink-3">{fmtScore(l.previous.score)}</span> | |
| 173 | + </> | |
| 174 | + ) : ( | |
| 175 | + <span className="text-ink-3">first leader recorded</span> | |
| 176 | + )} | |
| 177 | + </Td> | |
| 178 | + <Td label="Group" className="mono text-[11px] text-ink-3"> | |
| 179 | + {l.current?.group_label ?? '—'} | |
| 180 | + {num(l.current?.n_models) !== null && <span> · n={fmtInt(l.current?.n_models)}</span>} | |
| 181 | + </Td> | |
| 182 | + <Td label="Trust"><TrustChip level={l.current?.trust_level} /></Td> | |
| 183 | + </tr> | |
| 184 | + ))} | |
| 185 | + </tbody> | |
| 186 | + </DataTable> | |
| 187 | + </div> | |
| 188 | + )} | |
| 189 | + </Section> | |
| 190 | + | |
| 191 | + {/* ---------------------------------------------------------------------------------------------- prices */} | |
| 192 | + <Section eyebrow="Prices" title={`Price changes · ${fmtInt(counters.price_changes?.value)}${num(counters.price_changes?.median_percent) !== null ? ` · median ${fmtScore(counters.price_changes?.median_percent)} %` : ''}`} lede={counters.price_changes?.definition} action={{ href: routes.prices(), label: 'Price terminal' }}> | |
| 193 | + {!priceItems.length && priceRows.length > 0 && <Note className="mb-2">The pulse returns the count only; the rows below are PRICE_CHANGED events since {fmtDate(since)} from the change feed (same window, same backfill rule).</Note>} | |
| 194 | + <PriceMovers movers={priceRows} limit={30} /> | |
| 195 | + </Section> | |
| 196 | + | |
| 197 | + {/* --------------------------------------------------------------------------------------------- context */} | |
| 198 | + <Section eyebrow="Long context" title={`New ≥ 1M-context models · ${fmtInt(counters.new_models_1m_context?.value)}`} lede={counters.new_models_1m_context?.definition}> | |
| 199 | + {ctxModels.length > 0 ? ( | |
| 200 | + <ul className="border-t border-rule"> | |
| 201 | + {ctxModels.map((m) => ( | |
| 202 | + <li key={m.id} className="flex flex-wrap items-baseline gap-x-3 border-b border-rule py-2.5 text-sm"> | |
| 203 | + <EntityLink e={m} className="font-medium" /> | |
| 204 | + {m.organization && <span className="text-xs text-ink-3">{m.organization.name}</span>} | |
| 205 | + <span className="tnum ml-auto text-xs text-ink-2">{fmtTokens(m.attributes?.context_length)} · {typeof m.attributes?.release_date === 'string' ? fmtDate(m.attributes.release_date) : '—'}</span> | |
| 206 | + </li> | |
| 207 | + ))} | |
| 208 | + </ul> | |
| 209 | + ) : ctxEvents.length > 0 ? ( | |
| 210 | + <ul className="border-t border-rule">{ctxEvents.map((e) => <ChangeRow key={e.id} e={e} showDate live={false} />)}</ul> | |
| 211 | + ) : num(counters.new_models_1m_context?.value) === 0 ? ( | |
| 212 | + <> | |
| 213 | + <EmptyState title={`No new model with a ≥ 1M-token context in the last ${days} days`}> | |
| 214 | + {ctxFallback?.items.length ? <>For reference, the most recently released models with a sourced context of at least 1M tokens (any date):</> : null} | |
| 215 | + </EmptyState> | |
| 216 | + {ctxFallback?.items.length ? ( | |
| 217 | + <ul className="border-b border-rule"> | |
| 218 | + {ctxFallback.items.slice(0, 8).map((m) => ( | |
| 219 | + <li key={m.id} className="flex flex-wrap items-baseline gap-x-3 border-b border-rule py-2 text-sm last:border-b-0"> | |
| 220 | + <EntityLink e={m} /> | |
| 221 | + {m.organization && <span className="text-xs text-ink-3">{m.organization.name}</span>} | |
| 222 | + <span className="tnum ml-auto text-xs text-ink-2">{fmtTokens(m.attributes?.context_length)} · {typeof m.attributes?.release_date === 'string' ? fmtDate(m.attributes.release_date) : 'release date unavailable'}</span> | |
| 223 | + </li> | |
| 224 | + ))} | |
| 225 | + </ul> | |
| 226 | + ) : null} | |
| 227 | + </> | |
| 228 | + ) : ( | |
| 229 | + <EmptyState title={`${fmtInt(counters.new_models_1m_context?.value)} new ≥ 1M-context models`}>The pulse returns the count only; the list is not part of the API response yet.</EmptyState> | |
| 230 | + )} | |
| 231 | + </Section> | |
| 232 | + | |
| 233 | + <Section eyebrow="Reading the pulse" title="Definitions" hairline> | |
| 234 | + <dl className="grid gap-x-8 gap-y-3 md:grid-cols-2"> | |
| 235 | + {Object.entries(counters).map(([k, c]) => ( | |
| 236 | + <div key={k} className="border-b border-rule pb-2"> | |
| 237 | + <dt className="flex items-baseline justify-between gap-3 text-sm font-medium text-ink"> | |
| 238 | + {LABELS[k] ?? k.replace(/_/g, ' ')} <span className="tnum text-ink-2">{fmtInt(c.value)}</span> | |
| 239 | + </dt> | |
| 240 | + <dd className="mt-0.5 text-xs leading-relaxed text-ink-3">{c.definition}</dd> | |
| 241 | + </div> | |
| 242 | + ))} | |
| 243 | + </dl> | |
| 244 | + <Note className="mt-3"> | |
| 245 | + Source: <Link href="/developers" className="link">GET /pulse?days={days}</Link>. Nothing on this page is a projection. | |
| 246 | + </Note> | |
| 247 | + </Section> | |
| 248 | + </Container> | |
| 249 | + ); | |
| 250 | +} | |
added
apps/web/src/app/run-locally/page.tsx
+246 −0
@@ -0,0 +1,246 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 4 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 5 | +import { QUANTS } from '@/components/hardware/fit-form'; | |
| 6 | +import { EstimateBanner, FitBreakdownList, Methodology, SourceTag } from '@/components/intelligence/bits'; | |
| 7 | +import { CONTEXT_PRESETS, PLATFORMS, RunLocallyForm, type RunLocallyInputs, USE_CASES } from '@/components/intelligence/run-locally-form'; | |
| 8 | +import { TerminalLayout } from '@/components/layout/terminal'; | |
| 9 | +import { Chip, Estimated, OpennessBadge } from '@/components/ui/badges'; | |
| 10 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 11 | +import { EntityLink } from '@/components/ui/entity'; | |
| 12 | +import { Note, PageHeader } from '@/components/ui/section'; | |
| 13 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 14 | +import { ApiError, intel, safe } from '@/lib/api'; | |
| 15 | +import { fmtGb, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 16 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 17 | +import type { Fit, RunLocallyItem, RunLocallyPayload } from '@/lib/types'; | |
| 18 | + | |
| 19 | +export const revalidate = 600; | |
| 20 | +type SP = Record<string, string | undefined>; | |
| 21 | + | |
| 22 | +function parse(sp: SP): RunLocallyInputs { | |
| 23 | + const custom = num(sp.memory_custom); | |
| 24 | + const preset = num(sp.memory_gb); | |
| 25 | + const memory = custom !== null && custom > 0 ? custom : preset !== null && preset > 0 ? preset : null; | |
| 26 | + const gpu = num(sp.gpu_count); | |
| 27 | + const ctx = num(sp.context); | |
| 28 | + const batch = num(sp.batch); | |
| 29 | + return { | |
| 30 | + memory, | |
| 31 | + gpuCount: gpu !== null && [1, 2, 4, 8].includes(gpu) ? gpu : 1, | |
| 32 | + quant: QUANTS.some((q) => q.value === sp.quant) ? (sp.quant as string) : '4bit', | |
| 33 | + context: ctx !== null && ctx > 0 ? Math.round(ctx) : 8192, | |
| 34 | + batch: batch !== null && batch > 0 ? Math.round(batch) : 1, | |
| 35 | + platform: PLATFORMS.some((p) => p.value === sp.platform) ? (sp.platform as string) : 'any', | |
| 36 | + useCase: USE_CASES.some((u) => u.value === sp.use_case) ? (sp.use_case as string) : '', | |
| 37 | + openness: ['open-source', 'open-weights', 'restricted-weights'].includes(sp.openness ?? '') ? (sp.openness as string) : '', | |
| 38 | + hardware: sp.hardware?.trim() || null, | |
| 39 | + fitsOnly: sp.fits === '1', | |
| 40 | + }; | |
| 41 | +} | |
| 42 | + | |
| 43 | +const TITLE = 'Run locally — which AI models fit your machine? (estimated)'; | |
| 44 | +const DESC = 'Local AI Explorer: choose memory, GPU count, platform, quantization, context and batch; the atlas estimates which downloadable models fit — with the weight, KV-cache and overhead breakdown — and lists their compatible GGUF/MLX artifacts, using observed file sizes where a source records them. Every figure is an estimate.'; | |
| 45 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 46 | + const v = parse(await searchParams); | |
| 47 | + const title = v.hardware ? `What can ${v.hardware} run? (estimated)` : v.memory ? `Models estimated to fit ${fmtGb(v.memory * v.gpuCount)} at ${v.quant}, ${fmtTokens(v.context)} context` : TITLE; | |
| 48 | + return { title, description: DESC, alternates: { canonical: routes.runLocally() }, openGraph: { title: `${title} | ${SITE_NAME}`, description: DESC, url: `${SITE_URL}${routes.runLocally()}`, siteName: SITE_NAME }, robots: v.memory || v.hardware ? { index: false, follow: true } : undefined }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export default async function RunLocallyPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 52 | + const sp = await searchParams; | |
| 53 | + const v = parse(sp); | |
| 54 | + let res: RunLocallyPayload | null = null; | |
| 55 | + let hardwareName: string | null = null; | |
| 56 | + let memoryOptions: number[] | undefined; | |
| 57 | + let error: string | null = null; | |
| 58 | + let notFound = false; | |
| 59 | + if (v.hardware) { | |
| 60 | + try { | |
| 61 | + const h = await intel.hardwareSlugFit(v.hardware, { quant: v.quant, context: v.context, memory_gb: v.memory ?? undefined, gpu_count: v.gpuCount, openness: v.openness || undefined, limit: 300 }); | |
| 62 | + hardwareName = h.hardware.name; | |
| 63 | + memoryOptions = h.memory_options_gb; | |
| 64 | + res = { inputs: h.inputs, estimated: h.estimated, assumptions: h.assumptions, counts: h.counts, note: h.note ?? `Runtimes listed for this device: ${h.runtimes.join(', ') || 'unavailable'}.`, items: h.items.map(({ model, ...fit }) => ({ model, fit: fit as Fit, artifacts: [], artifact_count: 0 })) }; | |
| 65 | + } catch (e) { | |
| 66 | + if (e instanceof ApiError && e.notFound) notFound = true; | |
| 67 | + else error = e instanceof ApiError ? e.detail ?? e.message : 'API unreachable'; | |
| 68 | + } | |
| 69 | + } else if (v.memory) { | |
| 70 | + try { | |
| 71 | + res = await intel.runLocally({ memory_gb: v.memory, gpu_count: v.gpuCount, quant: v.quant, context: v.context, batch: v.batch, platform: v.platform, use_case: v.useCase || undefined, openness: v.openness || undefined, limit: 300 }); | |
| 72 | + } catch (e) { | |
| 73 | + error = e instanceof ApiError ? e.detail ?? e.message : 'API unreachable'; | |
| 74 | + } | |
| 75 | + } | |
| 76 | + const methodology = res ? null : await safe(intel.methodology()); | |
| 77 | + const items: RunLocallyItem[] = res ? (v.fitsOnly ? res.items.filter((i) => i.fit.fits) : res.items) : []; | |
| 78 | + const multiNote = v.gpuCount > 1 ? res?.items.find((i) => i.fit.multi_gpu_note)?.fit.multi_gpu_note ?? res?.note : null; | |
| 79 | + const total = v.memory ? v.memory * v.gpuCount : num(res?.inputs?.total_memory_gb); | |
| 80 | + const ctxLabel = CONTEXT_PRESETS.find((c) => c.value === v.context)?.label ?? fmtTokens(v.context); | |
| 81 | + const filterCount = [v.memory, v.gpuCount !== 1, v.quant !== '4bit', v.context !== 8192, v.batch !== 1, v.platform !== 'any', v.useCase, v.openness, v.fitsOnly].filter(Boolean).length; | |
| 82 | + const withArtifacts = items.filter((i) => i.artifacts.length > 0).length; | |
| 83 | + | |
| 84 | + const inspector = ( | |
| 85 | + <div className="space-y-3 text-xs leading-relaxed text-ink-2"> | |
| 86 | + <p className="flex items-center gap-2"> | |
| 87 | + <Estimated /> <span className="font-medium text-ink">Method</span> | |
| 88 | + </p> | |
| 89 | + <ul className="list-disc space-y-1 pl-4"> | |
| 90 | + {(res?.assumptions ?? methodology?.hardware_fit?.assumptions ?? []).map((a) => ( | |
| 91 | + <li key={a}>{a}</li> | |
| 92 | + ))} | |
| 93 | + </ul> | |
| 94 | + {methodology?.hardware_fit?.bytes_per_param && !res && ( | |
| 95 | + <p> | |
| 96 | + bytes / param:{' '} | |
| 97 | + {Object.entries(methodology.hardware_fit.bytes_per_param) | |
| 98 | + .map(([k, b]) => `${k} ${b}`) | |
| 99 | + .join(' · ')} | |
| 100 | + </p> | |
| 101 | + )} | |
| 102 | + <p> | |
| 103 | + <Link href="/methodology#estimates" className="link">/methodology</Link> · <Link href="/developers" className="link">GET /run-locally</Link> | |
| 104 | + </p> | |
| 105 | + </div> | |
| 106 | + ); | |
| 107 | + | |
| 108 | + return ( | |
| 109 | + <TerminalLayout filters={<RunLocallyForm v={v} hardwareName={hardwareName} memoryOptions={memoryOptions} />} inspector={inspector} filtersTitle="Machine" inspectorTitle="Method" storageKey="aia-inspector-run-locally" filterCount={filterCount}> | |
| 110 | + <PageHeader eyebrow={v.hardware ? 'What can this machine run?' : 'Local AI Explorer'} title={hardwareName ? `What can ${hardwareName} run?` : 'Run locally'} lede="Describe the machine — memory, number of devices, platform, quantization, context, batch — and the atlas lists the downloadable models estimated to fit, with the breakdown behind each number and the GGUF / MLX artifacts recorded for them. Estimates, never measurements." aside={<Estimated />} className="pt-4 md:pt-6" /> | |
| 111 | + | |
| 112 | + <div className="pb-16"> | |
| 113 | + {notFound ? ( | |
| 114 | + <EmptyState title={`No hardware with slug “${v.hardware}”`}> | |
| 115 | + Pick a device from the <Link href={routes.hardware()} className="link">hardware listing</Link> or describe the machine in the rail. | |
| 116 | + </EmptyState> | |
| 117 | + ) : !v.memory && !v.hardware ? ( | |
| 118 | + <EmptyState title="Describe a machine to start"> | |
| 119 | + Try <Link href="/run-locally?memory_gb=64&quant=4bit" className="link">64 GB · 4-bit</Link>, <Link href="/run-locally?memory_gb=24&gpu_count=2&quant=8bit&platform=nvidia" className="link">2 × 24 GB · 8-bit · NVIDIA</Link>, <Link href="/run-locally?memory_gb=128&quant=4bit&context=131072&use_case=coding&platform=apple" className="link">128 GB · 4-bit · 128K · coding</Link> — or start from a device on <Link href={routes.hardware()} className="link">/hardware</Link>. | |
| 120 | + </EmptyState> | |
| 121 | + ) : !res ? ( | |
| 122 | + <Unavailable what="Local fit estimate" reason={error ?? undefined} /> | |
| 123 | + ) : ( | |
| 124 | + <> | |
| 125 | + <EstimateBanner assumptions={res.assumptions} note={res.note} counts={res.counts} /> | |
| 126 | + <p className="tnum mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2"> | |
| 127 | + <span className="font-medium text-ink">{fmtGb(total)} total</span> | |
| 128 | + <span> | |
| 129 | + {v.gpuCount} × {fmtGb(v.memory ?? num(res.inputs?.memory_gb))} | |
| 130 | + </span> | |
| 131 | + <span>{QUANTS.find((q) => q.value === v.quant)?.label.split(' (')[0] ?? v.quant}</span> | |
| 132 | + <span>{ctxLabel} context</span> | |
| 133 | + <span>batch {v.batch}</span> | |
| 134 | + {v.platform !== 'any' && <span>{PLATFORMS.find((p) => p.value === v.platform)?.label.split(' (')[0]}</span>} | |
| 135 | + {v.useCase && <Chip tone="accent">{USE_CASES.find((u) => u.value === v.useCase)?.label}</Chip>} | |
| 136 | + <span className="ml-auto text-xs text-ink-3"> | |
| 137 | + {v.fitsOnly ? <Link href={`?${new URLSearchParams(Object.entries(sp).filter(([k, x]) => x && k !== 'fits') as [string, string][]).toString()}`} className="link">Show all evaluated models</Link> : <Link href={`?${new URLSearchParams({ ...(Object.fromEntries(Object.entries(sp).filter(([, x]) => x)) as Record<string, string>), fits: '1' }).toString()}`} className="link">Only fitting models</Link>} | |
| 138 | + </span> | |
| 139 | + </p> | |
| 140 | + {multiNote && v.gpuCount > 1 && ( | |
| 141 | + <Note className="mt-2 border-l-2 border-warning pl-3"> | |
| 142 | + <span className="font-medium text-warning">Multi-device:</span> {multiNote} | |
| 143 | + </Note> | |
| 144 | + )} | |
| 145 | + | |
| 146 | + {items.length === 0 ? ( | |
| 147 | + <EmptyState title={v.fitsOnly ? 'No model is estimated to fit' : 'No models to evaluate'} className="mt-6"> | |
| 148 | + {v.fitsOnly ? 'Try more memory, a lower-precision quantization, a shorter context or a smaller batch.' : 'Models without a sourced parameter count are not estimated.'} | |
| 149 | + </EmptyState> | |
| 150 | + ) : ( | |
| 151 | + <DataTable scroll compact className="mt-5"> | |
| 152 | + <thead> | |
| 153 | + <tr> | |
| 154 | + <Th>Model</Th> | |
| 155 | + <Th num>Params</Th> | |
| 156 | + <Th num>Est. memory</Th> | |
| 157 | + <Th num>Headroom</Th> | |
| 158 | + <Th>Fits</Th> | |
| 159 | + <Th>Breakdown</Th> | |
| 160 | + <Th>Artifacts</Th> | |
| 161 | + <Th className="w-24" aria-label="Compare" /> | |
| 162 | + </tr> | |
| 163 | + </thead> | |
| 164 | + <tbody> | |
| 165 | + {items.map((it, i) => { | |
| 166 | + const openness = typeof it.model.attributes?.openness === 'string' ? it.model.attributes.openness : null; | |
| 167 | + const head = num(it.fit.headroom_gb); | |
| 168 | + return ( | |
| 169 | + <RowGroup key={`${it.model.id}-${i}`} it={it} openness={openness} head={head} /> | |
| 170 | + ); | |
| 171 | + })} | |
| 172 | + {items.length === 0 && <EmptyRow cols={8} />} | |
| 173 | + </tbody> | |
| 174 | + </DataTable> | |
| 175 | + )} | |
| 176 | + <Note className="mt-3"> | |
| 177 | + Headroom = total device memory − reserve − estimate. {withArtifacts > 0 ? `${fmtInt(withArtifacts)} of ${fmtInt(items.length)} models have quantized artifacts recorded; ` : 'No quantized artifact is recorded for these models yet; '} | |
| 178 | + artifact rows use the <span className="text-positive">observed</span> file size when a source publishes it, otherwise the estimate. Sorted by the API (fitting models first). Compare shortlisted models with the + buttons. | |
| 179 | + </Note> | |
| 180 | + <Methodology text={res.note} /> | |
| 181 | + </> | |
| 182 | + )} | |
| 183 | + </div> | |
| 184 | + <CompareTrayBar /> | |
| 185 | + </TerminalLayout> | |
| 186 | + ); | |
| 187 | +} | |
| 188 | + | |
| 189 | +function RowGroup({ it, openness, head }: { it: RunLocallyItem; openness: string | null; head: number | null }) { | |
| 190 | + return ( | |
| 191 | + <> | |
| 192 | + <tr data-fit-row> | |
| 193 | + <Td primary> | |
| 194 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 195 | + <EntityLink e={it.model} /> | |
| 196 | + {openness && <OpennessBadge openness={openness} />} | |
| 197 | + </span> | |
| 198 | + {it.model.organization && <span className="block text-xs text-ink-3">{it.model.organization.name}</span>} | |
| 199 | + {typeof it.model.attributes?.license === 'string' && <span className="block text-[11px] text-ink-3">{it.model.attributes.license as string}</span>} | |
| 200 | + </Td> | |
| 201 | + <Td num label="Params" className="tnum">{fmtParams(it.fit.parameter_count ?? it.model.attributes?.parameter_count)}</Td> | |
| 202 | + <Td num label="Est. memory" className="tnum"> | |
| 203 | + {fmtGb(it.fit.estimated_memory_gb, 1)} <SourceTag source={it.fit.breakdown?.weights_source ?? 'estimated'} /> | |
| 204 | + </Td> | |
| 205 | + <Td num label="Headroom" className={it.fit.fits ? 'tnum text-positive' : 'tnum text-danger'}>{head === null ? '—' : `${head >= 0 ? '+' : '−'}${fmtGb(Math.abs(head), 1)}`}</Td> | |
| 206 | + <Td label="Fits" className={it.fit.fits ? 'font-medium text-positive' : 'text-ink-3'}>{it.fit.fits ? '✓ fits' : '✗ too large'}</Td> | |
| 207 | + <Td label="Breakdown" wide> | |
| 208 | + <FitBreakdownList fit={it.fit} /> | |
| 209 | + </Td> | |
| 210 | + <Td label="Artifacts" className="text-xs text-ink-2"> | |
| 211 | + {num(it.artifact_count) ? `${fmtInt(it.artifact_count)} recorded` : <span className="text-ink-3">none recorded</span>} | |
| 212 | + </Td> | |
| 213 | + <Td className="text-right"> | |
| 214 | + <CompareButton e={it.model} size="sm" /> | |
| 215 | + </Td> | |
| 216 | + </tr> | |
| 217 | + {it.artifacts.slice(0, 4).map((a, j) => ( | |
| 218 | + <tr key={`${a.artifact.id}-${j}`} className="bg-surface-2/40" data-artifact-row> | |
| 219 | + <Td primary className="!pl-6"> | |
| 220 | + <span className="text-xs text-ink-3">↳ </span> | |
| 221 | + <EntityLink e={a.artifact} className="text-sm" /> | |
| 222 | + {a.quant_format && <Chip className="ml-2 uppercase">{a.quant_format}</Chip>} | |
| 223 | + </Td> | |
| 224 | + <Td num label="File size" className="tnum text-xs"> | |
| 225 | + {num(a.file_size_gb) === null ? '—' : fmtGb(a.file_size_gb, 1)} <SourceTag source={a.weights_source} /> | |
| 226 | + </Td> | |
| 227 | + <Td num label="Est. memory" className="tnum text-xs">{fmtGb(a.fit.estimated_memory_gb, 1)}</Td> | |
| 228 | + <Td num label="Headroom" className={a.fit.fits ? 'tnum text-xs text-positive' : 'tnum text-xs text-danger'}>{num(a.fit.headroom_gb) === null ? '—' : `${(num(a.fit.headroom_gb) as number) >= 0 ? '+' : '−'}${fmtGb(Math.abs(num(a.fit.headroom_gb) as number), 1)}`}</Td> | |
| 229 | + <Td label="Fits" className={a.fit.fits ? 'text-xs font-medium text-positive' : 'text-xs text-ink-3'}>{a.fit.fits ? '✓ fits' : '✗ too large'}</Td> | |
| 230 | + <Td label="Breakdown" wide> | |
| 231 | + <FitBreakdownList fit={a.fit} /> | |
| 232 | + </Td> | |
| 233 | + <Td label="Artifacts" className="mono text-[11px] text-ink-3">{a.fit.quantization}</Td> | |
| 234 | + <Td /> | |
| 235 | + </tr> | |
| 236 | + ))} | |
| 237 | + {it.artifacts.length > 4 && ( | |
| 238 | + <tr className="bg-surface-2/40"> | |
| 239 | + <td colSpan={8} className="!py-1.5 pl-6 text-xs text-ink-3"> | |
| 240 | + +{it.artifacts.length - 4} more artifacts on the <EntityLink e={it.model} className="link">model page</EntityLink>. | |
| 241 | + </td> | |
| 242 | + </tr> | |
| 243 | + )} | |
| 244 | + </> | |
| 245 | + ); | |
| 246 | +} | |
modified
apps/web/src/app/tools/page.tsx
+11 −23
@@ -1,22 +1,22 @@ | ||
| 1 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | −import { Dash, str, TypedListing } from '@/components/listing/typed-listing'; | |
| 3 | +import { Dash, IntelListing, str } from '@/components/intelligence/listing-table'; | |
| 4 | 4 | import { EntityBadge } from '@/components/ui/badges'; |
| 5 | 5 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
| 6 | 6 | import { api } from '@/lib/api'; |
| 7 | 7 | import { fmtAgo, fmtInt, num } from '@/lib/format'; |
| 8 | −import { routes } from '@/lib/site'; | |
| 8 | +import { routes, SITE_NAME, SITE_URL } from '@/lib/site'; | |
| 9 | 9 | |
| 10 | −export const metadata: Metadata = { title: 'AI tools, agents and MCP servers', description: 'Developer tools, agents, products and MCP servers in the atlas with language, license, stars and publisher from their official sources.', alternates: { canonical: '/tools' } }; | |
| 10 | +export const metadata: Metadata = { title: 'AI tools, products and MCP servers', description: 'Developer tools, products and MCP servers in the atlas with category, language, license, stars and publisher from their official sources.', alternates: { canonical: routes.tools() }, openGraph: { title: `AI tools | ${SITE_NAME}`, url: `${SITE_URL}${routes.tools()}`, siteName: SITE_NAME } }; | |
| 11 | 11 | export const revalidate = 300; |
| 12 | 12 | |
| 13 | 13 | export default async function ToolsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 14 | 14 | const sp = await searchParams; |
| 15 | 15 | return ( |
| 16 | − <TypedListing | |
| 16 | + <IntelListing | |
| 17 | 17 | title="Tools" |
| 18 | − eyebrow="Tools" | |
| 19 | − lede="Agents, coding tools, MCP servers and products built on the models above. Metadata comes from repositories, package registries and official pages." | |
| 18 | + eyebrow="Agents & tools" | |
| 19 | + lede={<>Coding tools, MCP servers and products built on the models above. Metadata comes from repositories, package registries and official pages. Agents have their own listing at <Link href={routes.agents()} className="link">/agents</Link>.</>} | |
| 20 | 20 | basePath="/tools" |
| 21 | 21 | searchParams={sp} |
| 22 | 22 | fetch={(q) => api.explore('tool', q)} |
@@ -27,8 +27,8 @@ export default async function ToolsPage({ searchParams }: { searchParams: Promis | ||
| 27 | 27 | { value: 'quality', label: 'Data quality' }, |
| 28 | 28 | ]} |
| 29 | 29 | filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []} |
| 30 | − emptyTitle={sp.q ? 'No tools match' : 'No tools recorded yet'} | |
| 31 | − emptyHint={sp.q ? 'Try another name.' : <>Connectors are populating this type. Related software lives under <Link href={routes.frameworks()} className="link">Frameworks</Link> and <Link href={routes.exploreType('repository')} className="link">Repositories</Link> in the meantime.</>} | |
| 30 | + connectors={['github (tool repositories)', 'pypi / npm', 'official product pages']} | |
| 31 | + emptyHint={<>The <span className="mono">tool</span> type is declared but no connector has produced a row yet — the navigation lists this page under Agents & Tools for that reason. In the meantime, related software is under <Link href={routes.frameworks()} className="link">Frameworks</Link> and <Link href={routes.exploreType('repository')} className="link">Repositories</Link>.</>} | |
| 32 | 32 | columns={[ |
| 33 | 33 | { |
| 34 | 34 | key: 'name', |
@@ -47,24 +47,12 @@ export default async function ToolsPage({ searchParams }: { searchParams: Promis | ||
| 47 | 47 | { key: 'kind', label: 'Category', className: 'text-ink-2', render: (e) => str(e.attributes?.category) ?? str(e.attributes?.kind) ?? <Dash /> }, |
| 48 | 48 | { key: 'language', label: 'Language', className: 'text-ink-2', render: (e) => str(e.attributes?.language) ?? <Dash /> }, |
| 49 | 49 | { key: 'stars', label: 'Stars', num: true, className: 'tnum', render: (e) => (num(e.attributes?.['metric.stars']) === null ? <Dash /> : fmtInt(e.attributes['metric.stars'])) }, |
| 50 | − { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> }, | |
| 51 | − { | |
| 52 | − key: 'org', | |
| 53 | − label: 'Organization', | |
| 54 | − className: 'text-ink-2', | |
| 55 | − render: (e) => | |
| 56 | − e.organization ? ( | |
| 57 | − <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent"> | |
| 58 | − {e.organization.name} | |
| 59 | − </Link> | |
| 60 | − ) : ( | |
| 61 | − <Dash /> | |
| 62 | − ), | |
| 63 | − }, | |
| 50 | + { key: 'license', label: 'Licence', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> }, | |
| 51 | + { key: 'org', label: 'Organization', className: 'text-ink-2', render: (e) => (e.organization ? <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">{e.organization.name}</Link> : <Dash />) }, | |
| 64 | 52 | { key: 'updated', label: 'Updated', className: 'text-ink-2 whitespace-nowrap', render: (e) => <span title={e.updated_at}>{fmtAgo(e.updated_at)}</span> }, |
| 65 | 53 | { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> }, |
| 66 | 54 | ]} |
| 67 | − note="Tools cover several entity types (tool, agent, MCP server, product) — the badge on each row says which." | |
| 55 | + note="Tools cover several entity types (tool, MCP server, product) — the badge on each row says which." | |
| 68 | 56 | /> |
| 69 | 57 | ); |
| 70 | 58 | } |
added
apps/web/src/components/hardware/hardware-bits.tsx
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +import { fmtInt, num } from '@/lib/format'; | |
| 2 | +import type { EntitySummary } from '@/lib/types'; | |
| 3 | + | |
| 4 | +/* Hardware helpers shared by /hardware, /hardware/frontier and /hardware/[slug]. Attributes are read as published; nothing is inferred. */ | |
| 5 | + | |
| 6 | +export const HW_KINDS = ['gpu', 'accelerator', 'npu', 'cpu', 'soc', 'system', 'server', 'workstation', 'cloud-instance'] as const; | |
| 7 | +export const HW_KIND_LABELS: Record<string, string> = { gpu: 'GPU', accelerator: 'Accelerator', npu: 'NPU', cpu: 'CPU', soc: 'SoC', system: 'System', server: 'Server', workstation: 'Workstation', 'cloud-instance': 'Cloud instance', computer: 'Computer' }; | |
| 8 | + | |
| 9 | +/** memory_gb may be a number or a list of configurations → [min, max] or null. */ | |
| 10 | +export function memoryRange(v: unknown): [number, number] | null { | |
| 11 | + if (Array.isArray(v)) { | |
| 12 | + const xs = v.map(num).filter((x): x is number => x !== null); | |
| 13 | + return xs.length ? [Math.min(...xs), Math.max(...xs)] : null; | |
| 14 | + } | |
| 15 | + const n = num(v); | |
| 16 | + return n === null ? null : [n, n]; | |
| 17 | +} | |
| 18 | +export function memoryOptions(v: unknown): number[] { | |
| 19 | + if (Array.isArray(v)) return v.map(num).filter((x): x is number => x !== null).sort((a, b) => a - b); | |
| 20 | + const n = num(v); | |
| 21 | + return n === null ? [] : [n]; | |
| 22 | +} | |
| 23 | +export function fmtMemory(v: unknown): string { | |
| 24 | + const r = memoryRange(v); | |
| 25 | + if (!r) return '—'; | |
| 26 | + return r[0] === r[1] ? `${fmtInt(r[0])} GB` : `${fmtInt(r[0])}–${fmtInt(r[1])} GB`; | |
| 27 | +} | |
| 28 | +export function str(v: unknown): string | null { | |
| 29 | + return typeof v === 'string' && v.trim() ? v : null; | |
| 30 | +} | |
| 31 | +export function list(v: unknown): string[] { | |
| 32 | + return Array.isArray(v) ? v.filter((x) => x !== null && x !== undefined).map(String) : []; | |
| 33 | +} | |
| 34 | +/** Precision support as published (`precision_support`, `precisions`, `supported_precisions`, `dtypes`). */ | |
| 35 | +export function precisions(a: Record<string, unknown>): string[] { | |
| 36 | + for (const k of ['precision_support', 'precisions', 'supported_precisions', 'dtypes']) { | |
| 37 | + const l = list(a[k]); | |
| 38 | + if (l.length) return l; | |
| 39 | + } | |
| 40 | + return []; | |
| 41 | +} | |
| 42 | +export function interconnect(a: Record<string, unknown>): string | null { | |
| 43 | + for (const k of ['interconnect', 'interconnect_bandwidth_gbs', 'nvlink_bandwidth_gbs', 'fabric']) { | |
| 44 | + const v = a[k]; | |
| 45 | + if (typeof v === 'string' && v.trim()) return v; | |
| 46 | + if (num(v) !== null) return `${fmtInt(v)} GB/s`; | |
| 47 | + } | |
| 48 | + return null; | |
| 49 | +} | |
| 50 | +export function releaseOf(e: EntitySummary): string | null { | |
| 51 | + return str(e.attributes?.release_date) ?? str(e.attributes?.announced_at) ?? null; | |
| 52 | +} | |
| 53 | +/** Sort key for a release: "2024", "2024-10" or full date → ms; null when absent. */ | |
| 54 | +export function releaseMs(r: string | null): number | null { | |
| 55 | + if (!r) return null; | |
| 56 | + const t = new Date(r.length === 4 ? `${r}-07-01T00:00:00Z` : r.length === 7 ? `${r}-15T00:00:00Z` : r.length === 10 ? `${r}T00:00:00Z` : r).getTime(); | |
| 57 | + return Number.isNaN(t) ? null : t; | |
| 58 | +} | |
added
apps/web/src/components/intelligence/bits.tsx
+295 −0
@@ -0,0 +1,295 @@ | ||
| 1 | +import { Check, Minus, X } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import type { ReactNode } from 'react'; | |
| 4 | +import { Estimated } from '@/components/ui/badges'; | |
| 5 | +import { Hint } from '@/components/ui/hint'; | |
| 6 | +import { Note } from '@/components/ui/section'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { fmtGb, fmtInt, fmtUsdPerM, num } from '@/lib/format'; | |
| 9 | +import type { Fit, ModelLicence, Num, PriceDistribution, PriceDistributionStats } from '@/lib/types'; | |
| 10 | + | |
| 11 | +/* | |
| 12 | + Small server-safe building blocks shared by the intelligence surfaces (frontier · prices · providers · open · run-locally …). | |
| 13 | + Every number is passed in from the API; nothing here computes a score. | |
| 14 | +*/ | |
| 15 | + | |
| 16 | +/** Methodology footnote under a section: the API's own `methodology` / `note` string, never paraphrased into a claim. */ | |
| 17 | +export function Methodology({ text, children, className }: { text?: string | null; children?: ReactNode; className?: string }) { | |
| 18 | + if (!text && !children) return null; | |
| 19 | + return ( | |
| 20 | + <Note className={cn('mt-3 max-w-4xl', className)}> | |
| 21 | + <span className="eyebrow mr-1.5 text-[10px]">Method</span> | |
| 22 | + {text} | |
| 23 | + {children}{' '} | |
| 24 | + <Link href="/methodology" className="text-ink-3 underline decoration-dotted hover:text-ink"> | |
| 25 | + /methodology | |
| 26 | + </Link> | |
| 27 | + </Note> | |
| 28 | + ); | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** Tiny min · p25 · median · p75 · max range bar on a log₁₀ axis shared by the whole column (`domain`). Amber = money. */ | |
| 32 | +export function RangeBar({ d, domain, format = (v) => fmtUsdPerM(v), className, label }: { d: PriceDistributionStats | null | undefined; domain: [number, number]; format?: (v: number) => string; className?: string; label?: string }) { | |
| 33 | + const min = num(d?.min); | |
| 34 | + const p25 = num(d?.p25); | |
| 35 | + const med = num(d?.median); | |
| 36 | + const p75 = num(d?.p75); | |
| 37 | + const max = num(d?.max); | |
| 38 | + if (min === null || max === null || med === null) return <span className="text-xs text-ink-3">—</span>; | |
| 39 | + const [lo, hi] = domain; | |
| 40 | + const L = (v: number) => Math.log10(Math.max(v, lo)); | |
| 41 | + const pct = (v: number) => (hi === lo ? 50 : ((L(v) - L(lo)) / (L(hi) - L(lo))) * 100); | |
| 42 | + const title = `${label ?? 'Distribution'}: min ${format(min)} · p25 ${p25 === null ? '—' : format(p25)} · median ${format(med)} · p75 ${p75 === null ? '—' : format(p75)} · max ${format(max)}${num(d?.n) !== null ? ` · n=${fmtInt(d?.n)}` : ''}`; | |
| 43 | + return ( | |
| 44 | + <span className={cn('inline-flex items-center gap-2', className)} title={title} aria-label={title}> | |
| 45 | + <span className="relative block h-3 w-24 shrink-0" aria-hidden> | |
| 46 | + <span className="absolute inset-y-[5px] left-0 right-0 rounded-sm bg-surface-3" /> | |
| 47 | + <span className="absolute inset-y-[5px] rounded-sm bg-accent-2/60" style={{ left: `${pct(min)}%`, width: `${Math.max(1, pct(max) - pct(min))}%` }} /> | |
| 48 | + {p25 !== null && p75 !== null && <span className="absolute inset-y-[3px] rounded-sm bg-accent-2" style={{ left: `${pct(p25)}%`, width: `${Math.max(1.5, pct(p75) - pct(p25))}%` }} />} | |
| 49 | + <span className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `calc(${pct(med)}% - 1px)` }} /> | |
| 50 | + </span> | |
| 51 | + <span className="tnum text-xs text-ink-2"> | |
| 52 | + {format(med)} <span className="text-ink-3">med</span> | |
| 53 | + </span> | |
| 54 | + </span> | |
| 55 | + ); | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** Log-domain helper for a column of distributions. */ | |
| 59 | +export function distDomain(items: (PriceDistributionStats | null | undefined)[]): [number, number] { | |
| 60 | + const mins = items.map((d) => num(d?.min)).filter((v): v is number => v !== null && v > 0); | |
| 61 | + const maxs = items.map((d) => num(d?.max)).filter((v): v is number => v !== null && v > 0); | |
| 62 | + if (!mins.length || !maxs.length) return [0.01, 100]; | |
| 63 | + return [Math.min(...mins), Math.max(...maxs)]; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** Histogram of current offers by price bucket (from `/prices/index.distribution`). */ | |
| 67 | +export function DistBars({ d, className }: { d: PriceDistribution | null | undefined; className?: string }) { | |
| 68 | + const buckets = d?.buckets ?? []; | |
| 69 | + const vals = buckets.map((b) => num(b.offers) ?? 0); | |
| 70 | + const max = Math.max(1, ...vals); | |
| 71 | + if (!buckets.length) return <p className={cn('text-xs text-ink-3', className)}>No distribution returned.</p>; | |
| 72 | + return ( | |
| 73 | + <div className={className}> | |
| 74 | + <ol className="flex h-28 items-end gap-1" role="img" aria-label={`Current offers by ${d?.metric ?? 'price'} bucket`}> | |
| 75 | + {buckets.map((b, i) => { | |
| 76 | + const v = vals[i] ?? 0; | |
| 77 | + return ( | |
| 78 | + <li key={b.label} className="group relative flex min-w-0 flex-1 flex-col items-center justify-end" title={`${b.label}: ${fmtInt(v)} offers`}> | |
| 79 | + <span className="tnum mb-0.5 text-[10px] text-ink-3">{v ? fmtInt(v) : ''}</span> | |
| 80 | + <span className="block w-full rounded-t-[2px] bg-accent-2/80 group-hover:bg-accent-2" style={{ height: `${Math.max(2, (v / max) * 80)}px` }} /> | |
| 81 | + </li> | |
| 82 | + ); | |
| 83 | + })} | |
| 84 | + </ol> | |
| 85 | + <ol className="mt-1 flex gap-1 border-t border-rule pt-1" aria-hidden> | |
| 86 | + {buckets.map((b) => ( | |
| 87 | + <li key={b.label} className="tnum min-w-0 flex-1 truncate text-center text-[9px] text-ink-3 sm:text-[10px]"> | |
| 88 | + {b.label} | |
| 89 | + </li> | |
| 90 | + ))} | |
| 91 | + </ol> | |
| 92 | + <p className="tnum mt-1 text-xs text-ink-3"> | |
| 93 | + {fmtInt(d?.offers)} current offers · {d?.unit ?? 'USD per 1M tokens'} · {d?.metric?.replace(/_per_mtok$/, '').replace(/_/g, ' ')} | |
| 94 | + </p> | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | +} | |
| 98 | + | |
| 99 | +/** Estimated fit result: ✓/✗ + estimated memory + headroom, always next to an `Estimated` label. `compact` for table cells. */ | |
| 100 | +export function FitCell({ fit, compact = false, className }: { fit: Partial<Fit> | null | undefined; compact?: boolean; className?: string }) { | |
| 101 | + if (!fit || fit.fits === null || fit.fits === undefined) return <span className={cn('text-xs text-ink-3', className)} aria-label="no estimate">—</span>; | |
| 102 | + const mem = num(fit.estimated_memory_gb); | |
| 103 | + const head = num(fit.headroom_gb); | |
| 104 | + return ( | |
| 105 | + <span className={cn('tnum inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs', className)}> | |
| 106 | + <span className={fit.fits ? 'font-medium text-positive' : 'text-danger'}>{fit.fits ? '✓ fits' : '✗ too large'}</span> | |
| 107 | + {mem !== null && <span className="text-ink-2">{fmtGb(mem, 1)}</span>} | |
| 108 | + {!compact && head !== null && <span className="text-ink-3">{head >= 0 ? '+' : '−'}{fmtGb(Math.abs(head), 1)} headroom</span>} | |
| 109 | + {fit.quantization && <span className="mono text-[10px] text-ink-3">{fit.quantization}</span>} | |
| 110 | + </span> | |
| 111 | + ); | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** Fit breakdown (weights est./observed · KV cache + method · overhead · reserve) as a compact inline list. */ | |
| 115 | +export function FitBreakdownList({ fit, className }: { fit: Fit; className?: string }) { | |
| 116 | + const b = fit.breakdown; | |
| 117 | + if (!b) return fit.note ? <p className={cn('text-xs text-ink-3', className)}>{fit.note}</p> : null; | |
| 118 | + return ( | |
| 119 | + <ul className={cn('tnum flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-ink-3', className)}> | |
| 120 | + <li> | |
| 121 | + weights <span className="text-ink-2">{fmtGb(b.weights_gb, 1)}</span> <SourceTag source={b.weights_source} /> | |
| 122 | + </li> | |
| 123 | + <li> | |
| 124 | + KV cache <span className="text-ink-2">{fmtGb(b.kv_cache_gb, 2)}</span> <span className="mono">({b.kv_cache_method})</span> | |
| 125 | + </li> | |
| 126 | + <li> | |
| 127 | + overhead <span className="text-ink-2">{fmtGb(b.overhead_gb, 1)}</span> | |
| 128 | + </li> | |
| 129 | + <li> | |
| 130 | + reserved <span className="text-ink-2">{fmtGb(b.reserved_gb, 0)}</span> | |
| 131 | + </li> | |
| 132 | + <li> | |
| 133 | + context <span className="text-ink-2">{fmtInt(b.context)}</span> × batch {fmtInt(b.batch)} | |
| 134 | + </li> | |
| 135 | + </ul> | |
| 136 | + ); | |
| 137 | +} | |
| 138 | + | |
| 139 | +/** "observed" (green, file size read from the artifact) vs "estimated" (dashed warning). */ | |
| 140 | +export function SourceTag({ source }: { source: string | null | undefined }) { | |
| 141 | + if (source === 'observed') return <span className="rounded-[3px] bg-positive-soft px-1 text-[10px] font-medium uppercase tracking-wide text-positive">observed</span>; | |
| 142 | + return <span className="rounded-[3px] border border-dashed border-warning/60 px-1 text-[10px] font-medium uppercase tracking-wide text-warning">estimated</span>; | |
| 143 | +} | |
| 144 | + | |
| 145 | +/** Banner above any table of estimates: label + the API assumptions (never our own wording). */ | |
| 146 | +export function EstimateBanner({ assumptions, note, counts, className }: { assumptions?: string[]; note?: string | null; counts?: { fits?: Num; evaluated?: Num } | null; className?: string }) { | |
| 147 | + return ( | |
| 148 | + <div className={cn('border-y border-rule py-3', className)} data-estimate-banner> | |
| 149 | + <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm"> | |
| 150 | + <Estimated /> | |
| 151 | + <span className="font-semibold text-ink">All fit figures are estimates, not measurements.</span> | |
| 152 | + {counts && num(counts.fits) !== null && num(counts.evaluated) !== null && ( | |
| 153 | + <span className="tnum text-ink-3"> | |
| 154 | + <span className="font-medium text-positive">{fmtInt(counts.fits)}</span> of {fmtInt(counts.evaluated)} evaluated models fit | |
| 155 | + </span> | |
| 156 | + )} | |
| 157 | + </p> | |
| 158 | + {note && <p className="mt-1.5 text-xs leading-relaxed text-ink-2">{note}</p>} | |
| 159 | + {assumptions && assumptions.length > 0 && ( | |
| 160 | + <details className="mt-1.5 text-xs text-ink-3"> | |
| 161 | + <summary className="cursor-pointer select-none hover:text-ink">Assumptions ({assumptions.length})</summary> | |
| 162 | + <ul className="mt-1 list-disc space-y-0.5 pl-5 leading-relaxed"> | |
| 163 | + {assumptions.map((a) => ( | |
| 164 | + <li key={a}>{a}</li> | |
| 165 | + ))} | |
| 166 | + </ul> | |
| 167 | + </details> | |
| 168 | + )} | |
| 169 | + </div> | |
| 170 | + ); | |
| 171 | +} | |
| 172 | + | |
| 173 | +/** Licence permission glyphs: commercial · redistribution · derivatives · hosting (✓ allowed · ✗ restricted · – unknown). */ | |
| 174 | +export function LicencePerms({ l, className, withLabel = false }: { l: ModelLicence | null | undefined; className?: string; withLabel?: boolean }) { | |
| 175 | + if (!l || l.key === null) { | |
| 176 | + return ( | |
| 177 | + <span className={cn('inline-flex flex-wrap items-center gap-1 text-xs text-ink-3', className)} title={l && 'note' in l && l.note ? l.note : 'Licence not classified'}> | |
| 178 | + {l?.raw ? <span className="max-w-[9rem] truncate text-ink-2">{l.raw}</span> : <span>—</span>} | |
| 179 | + <span className="rounded-[3px] bg-surface-2 px-1 text-[10px] uppercase tracking-wide">unclassified</span> | |
| 180 | + </span> | |
| 181 | + ); | |
| 182 | + } | |
| 183 | + const items: [string, boolean | null | undefined, boolean][] = [ | |
| 184 | + ['Commercial use', l.commercial_use, false], | |
| 185 | + ['Redistribution', l.redistribution, false], | |
| 186 | + ['Derivatives', l.derivatives, false], | |
| 187 | + ['Hosting', l.hosting_restrictions === null || l.hosting_restrictions === undefined ? null : !l.hosting_restrictions, false], | |
| 188 | + ]; | |
| 189 | + return ( | |
| 190 | + <span className={cn('inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs', className)}> | |
| 191 | + {withLabel && <span className="mr-1 truncate text-ink">{l.label ?? l.key}</span>} | |
| 192 | + {!withLabel && <span className="mono mr-0.5 text-[11px] text-ink-2">{l.key}</span>} | |
| 193 | + {items.map(([label, v]) => ( | |
| 194 | + <span key={label} className={cn('inline-flex size-4 items-center justify-center rounded-[3px]', v === true ? 'bg-positive-soft text-positive' : v === false ? 'bg-danger-soft text-danger' : 'bg-surface-2 text-ink-3')} title={`${label}: ${v === true ? 'allowed' : v === false ? 'restricted' : 'unknown'}`} aria-label={`${label}: ${v === true ? 'allowed' : v === false ? 'restricted' : 'unknown'}`}> | |
| 195 | + {v === true ? <Check className="size-3" aria-hidden /> : v === false ? <X className="size-3" aria-hidden /> : <Minus className="size-3" aria-hidden />} | |
| 196 | + </span> | |
| 197 | + ))} | |
| 198 | + </span> | |
| 199 | + ); | |
| 200 | +} | |
| 201 | +export function LicenceLegend({ className }: { className?: string }) { | |
| 202 | + return ( | |
| 203 | + <span className={cn('flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-3', className)}> | |
| 204 | + <span>Permissions, in order:</span> | |
| 205 | + <span>commercial use</span> | |
| 206 | + <span>· redistribution</span> | |
| 207 | + <span>· derivatives</span> | |
| 208 | + <span>· hosting</span> | |
| 209 | + <span className="inline-flex items-center gap-1"> | |
| 210 | + <span className="inline-flex size-3.5 items-center justify-center rounded-[2px] bg-positive-soft text-positive"><Check className="size-2.5" aria-hidden /></span> allowed | |
| 211 | + </span> | |
| 212 | + <span className="inline-flex items-center gap-1"> | |
| 213 | + <span className="inline-flex size-3.5 items-center justify-center rounded-[2px] bg-danger-soft text-danger"><X className="size-2.5" aria-hidden /></span> restricted | |
| 214 | + </span> | |
| 215 | + <span className="inline-flex items-center gap-1"> | |
| 216 | + <span className="inline-flex size-3.5 items-center justify-center rounded-[2px] bg-surface-2 text-ink-3"><Minus className="size-2.5" aria-hidden /></span> unknown | |
| 217 | + </span> | |
| 218 | + </span> | |
| 219 | + ); | |
| 220 | +} | |
| 221 | + | |
| 222 | +/** Trust level chip (leaderboard rows). */ | |
| 223 | +const TRUST_TONE: Record<string, string> = { | |
| 224 | + 'official-benchmark': 'bg-positive-soft text-positive', | |
| 225 | + 'peer-reviewed': 'bg-positive-soft text-positive', | |
| 226 | + 'independent-evaluator': 'bg-accent-soft text-accent', | |
| 227 | + 'official-model-card': 'bg-warning-soft text-warning', | |
| 228 | + community: 'bg-surface-2 text-ink-2', | |
| 229 | + unverified: 'bg-danger-soft text-danger', | |
| 230 | +}; | |
| 231 | +export function TrustChip({ level, label, className }: { level: string | null | undefined; label?: string | null; className?: string }) { | |
| 232 | + if (!level) return <span className={cn('text-xs text-ink-3', className)}>—</span>; | |
| 233 | + return ( | |
| 234 | + <span className={cn('inline-flex items-center whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4', TRUST_TONE[level] ?? 'bg-surface-2 text-ink-2', className)} title={label ?? level}> | |
| 235 | + {level.replace(/-/g, ' ')} | |
| 236 | + </span> | |
| 237 | + ); | |
| 238 | +} | |
| 239 | + | |
| 240 | +/** Rank chips "benchmark #n" (observed dimensions, never summed). */ | |
| 241 | +export function RankChips({ ranks, max = 4, className }: { ranks: Record<string, number> | { benchmark: string; rank: number }[] | undefined | null; max?: number; className?: string }) { | |
| 242 | + const arr = !ranks ? [] : Array.isArray(ranks) ? ranks : Object.entries(ranks).map(([benchmark, rank]) => ({ benchmark, rank })); | |
| 243 | + const sorted = arr.slice().sort((a, b) => a.rank - b.rank); | |
| 244 | + if (!sorted.length) return <span className={cn('text-xs text-ink-3', className)}>—</span>; | |
| 245 | + return ( | |
| 246 | + <span className={cn('inline-flex flex-wrap gap-1', className)}> | |
| 247 | + {sorted.slice(0, max).map((r) => ( | |
| 248 | + <Link key={r.benchmark} href={`/benchmarks/${encodeURIComponent(r.benchmark)}`} className="tnum inline-flex items-center gap-1 rounded-[3px] bg-surface-2 px-1.5 py-[1px] text-[11px] text-ink-2 hover:text-accent" title={`Rank ${r.rank} on ${r.benchmark} (primary comparability group)`}> | |
| 249 | + <span className="truncate max-w-[7rem]">{r.benchmark}</span> | |
| 250 | + <span className="font-medium text-ink">#{r.rank}</span> | |
| 251 | + </Link> | |
| 252 | + ))} | |
| 253 | + {sorted.length > max && <span className="text-[11px] text-ink-3">+{sorted.length - max}</span>} | |
| 254 | + </span> | |
| 255 | + ); | |
| 256 | +} | |
| 257 | + | |
| 258 | +/** Column header with a sort link (keeps the rest of the URL). */ | |
| 259 | +export function SortTh({ active, href, children, num: n, dir = 'asc' }: { active: boolean; href: string; children: ReactNode; num?: boolean; dir?: 'asc' | 'desc' }) { | |
| 260 | + return ( | |
| 261 | + <th scope="col" className={cn(n && 'num')} aria-sort={active ? (dir === 'asc' ? 'ascending' : 'descending') : undefined}> | |
| 262 | + <Link href={href} className={active ? 'text-ink' : 'hover:text-ink'}> | |
| 263 | + {children} | |
| 264 | + {active && <span aria-hidden> {dir === 'asc' ? '↑' : '↓'}</span>} | |
| 265 | + </Link> | |
| 266 | + </th> | |
| 267 | + ); | |
| 268 | +} | |
| 269 | + | |
| 270 | +/** Label + definition tooltip pair used in rails and strips. */ | |
| 271 | +export function Defined({ label, definition }: { label: ReactNode; definition?: string | null }) { | |
| 272 | + return ( | |
| 273 | + <span className="inline-flex items-center gap-0.5"> | |
| 274 | + {label} | |
| 275 | + {definition && <Hint text={definition} />} | |
| 276 | + </span> | |
| 277 | + ); | |
| 278 | +} | |
| 279 | + | |
| 280 | +/** Shared form control classes for the GET forms in rails. */ | |
| 281 | +export const CTRL = 'h-11 lg:h-10 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none'; | |
| 282 | +export const CTRL_LG = 'h-11 w-full border border-rule bg-surface px-2.5 text-[15px] text-ink focus:border-accent focus:outline-none'; | |
| 283 | +export const BTN_PRIMARY = 'inline-flex h-11 items-center justify-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90'; | |
| 284 | +export const BTN_GHOST = 'inline-flex h-11 lg:h-10 items-center justify-center border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink'; | |
| 285 | + | |
| 286 | +/** Field wrapper for rail forms. */ | |
| 287 | +export function Field({ label, children, hint, className }: { label: string; children: ReactNode; hint?: ReactNode; className?: string }) { | |
| 288 | + return ( | |
| 289 | + <label className={cn('block min-w-0', className)}> | |
| 290 | + <span className="eyebrow block pb-1">{label}</span> | |
| 291 | + {children} | |
| 292 | + {hint && <span className="mt-0.5 block text-[11px] text-ink-3">{hint}</span>} | |
| 293 | + </label> | |
| 294 | + ); | |
| 295 | +} | |
added
apps/web/src/components/intelligence/calculator.tsx
+390 −0
@@ -0,0 +1,390 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ExternalLink } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { usePathname, useSearchParams } from 'next/navigation'; | |
| 5 | +import { useCallback, useEffect, useMemo, useState } from 'react'; | |
| 6 | +import { Chip } from '@/components/ui/badges'; | |
| 7 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 8 | +import { EntityLink } from '@/components/ui/entity'; | |
| 9 | +import { Note } from '@/components/ui/section'; | |
| 10 | +import { clientIntel } from '@/lib/client-api'; | |
| 11 | +import { cn } from '@/lib/cn'; | |
| 12 | +import { fmtAgo, fmtInt, fmtTokens, fmtUsdPerM, hostOf, num } from '@/lib/format'; | |
| 13 | +import { routes } from '@/lib/site'; | |
| 14 | +import type { CostContextPayload, CostPayload } from '@/lib/types'; | |
| 15 | +import { CTRL_LG, Field, Methodology } from './bits'; | |
| 16 | +import { ModelPicker } from './model-picker'; | |
| 17 | + | |
| 18 | +/* | |
| 19 | + Cost calculator (client, same-origin API only). Two tabs: | |
| 20 | + - Workload: `/cost?model=&provider=&input_tokens=&output_tokens=&requests_per_day=&cached_share=&batch=` → per request / daily / | |
| 21 | + monthly / annual for EVERY current deployment side by side. The cheapest column is bold — that is a fact about this workload, | |
| 22 | + not a verdict about the model. | |
| 23 | + - Context cost: `/cost/context?tokens=` → "how much does a fully populated N-token context cost?" for every offer whose context ≥ N. | |
| 24 | + All inputs live in the URL (shareable); every number and the methodology come from the API. | |
| 25 | +*/ | |
| 26 | +const PRESETS_CTX = [ | |
| 27 | + { value: 128_000, label: '128K' }, | |
| 28 | + { value: 200_000, label: '200K' }, | |
| 29 | + { value: 1_000_000, label: '1M' }, | |
| 30 | + { value: 2_000_000, label: '2M' }, | |
| 31 | +]; | |
| 32 | +const fmtMoney = (v: unknown, digits?: number) => { | |
| 33 | + const n = num(v); | |
| 34 | + if (n === null) return '—'; | |
| 35 | + if (digits !== undefined) return `$${n.toFixed(digits)}`; | |
| 36 | + if (n === 0) return '$0'; | |
| 37 | + if (n < 0.001) return `$${n.toFixed(6).replace(/0+$/, '')}`; | |
| 38 | + if (n < 1) return `$${n.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')}`; | |
| 39 | + if (n < 1000) return `$${n.toFixed(2)}`; | |
| 40 | + return `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(n)}`; | |
| 41 | +}; | |
| 42 | + | |
| 43 | +type State = { model: string; modelName: string; provider: string; input_tokens: string; output_tokens: string; requests_per_day: string; cached: string; batch: boolean; tab: 'workload' | 'context'; tokens: string }; | |
| 44 | + | |
| 45 | +function read(sp: URLSearchParams): State { | |
| 46 | + return { | |
| 47 | + model: sp.get('model') ?? '', | |
| 48 | + modelName: sp.get('name') ?? '', | |
| 49 | + provider: sp.get('provider') ?? '', | |
| 50 | + input_tokens: sp.get('input_tokens') ?? '1000', | |
| 51 | + output_tokens: sp.get('output_tokens') ?? '500', | |
| 52 | + requests_per_day: sp.get('requests_per_day') ?? '1000', | |
| 53 | + cached: sp.get('cached') ?? '0', | |
| 54 | + batch: sp.get('batch') === '1', | |
| 55 | + tab: sp.get('tab') === 'context' ? 'context' : 'workload', | |
| 56 | + tokens: sp.get('tokens') ?? '1000000', | |
| 57 | + }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export function Calculator() { | |
| 61 | + const sp = useSearchParams(); | |
| 62 | + const pathname = usePathname(); | |
| 63 | + const [s, setS] = useState<State>(() => read(new URLSearchParams(sp.toString()))); | |
| 64 | + const [cost, setCost] = useState<CostPayload | null>(null); | |
| 65 | + const [ctx, setCtx] = useState<CostContextPayload | null>(null); | |
| 66 | + const [busy, setBusy] = useState(false); | |
| 67 | + const [error, setError] = useState<string | null>(null); | |
| 68 | + | |
| 69 | + // URL ← state (debounced, replace) | |
| 70 | + useEffect(() => { | |
| 71 | + const t = setTimeout(() => { | |
| 72 | + const p = new URLSearchParams(); | |
| 73 | + if (s.model) p.set('model', s.model); | |
| 74 | + if (s.modelName && s.modelName !== s.model) p.set('name', s.modelName); | |
| 75 | + if (s.provider) p.set('provider', s.provider); | |
| 76 | + if (s.input_tokens !== '1000') p.set('input_tokens', s.input_tokens); | |
| 77 | + if (s.output_tokens !== '500') p.set('output_tokens', s.output_tokens); | |
| 78 | + if (s.requests_per_day !== '1000') p.set('requests_per_day', s.requests_per_day); | |
| 79 | + if (s.cached !== '0') p.set('cached', s.cached); | |
| 80 | + if (s.batch) p.set('batch', '1'); | |
| 81 | + if (s.tab === 'context') p.set('tab', 'context'); | |
| 82 | + if (s.tokens !== '1000000') p.set('tokens', s.tokens); | |
| 83 | + const q = p.toString(); | |
| 84 | + const next = q ? `${pathname}?${q}` : pathname; | |
| 85 | + const current = `${window.location.pathname}${window.location.search}`; | |
| 86 | + // replaceState (not router.replace): the URL mirrors the inputs without a server round trip on every keystroke | |
| 87 | + if (next !== current) window.history.replaceState(null, '', next); | |
| 88 | + }, 250); | |
| 89 | + return () => clearTimeout(t); | |
| 90 | + }, [s, pathname]); | |
| 91 | + | |
| 92 | + const costQs = useMemo(() => { | |
| 93 | + if (!s.model) return null; | |
| 94 | + const p = new URLSearchParams({ model: s.model }); | |
| 95 | + if (s.provider) p.set('provider', s.provider); | |
| 96 | + p.set('input_tokens', String(Math.max(0, Math.round(num(s.input_tokens) ?? 0)))); | |
| 97 | + p.set('output_tokens', String(Math.max(0, Math.round(num(s.output_tokens) ?? 0)))); | |
| 98 | + p.set('requests_per_day', String(Math.max(0, num(s.requests_per_day) ?? 0))); | |
| 99 | + p.set('cached_share', String(Math.min(1, Math.max(0, (num(s.cached) ?? 0) / 100)))); | |
| 100 | + p.set('batch', s.batch ? '1' : '0'); | |
| 101 | + return p.toString(); | |
| 102 | + }, [s.model, s.provider, s.input_tokens, s.output_tokens, s.requests_per_day, s.cached, s.batch]); | |
| 103 | + | |
| 104 | + useEffect(() => { | |
| 105 | + if (s.tab !== 'workload' || !costQs) return; | |
| 106 | + const ctrl = new AbortController(); | |
| 107 | + setBusy(true); | |
| 108 | + setError(null); | |
| 109 | + const t = setTimeout(() => { | |
| 110 | + clientIntel | |
| 111 | + .cost(costQs, ctrl.signal) | |
| 112 | + .then((r) => { | |
| 113 | + setCost(r); | |
| 114 | + setBusy(false); | |
| 115 | + }) | |
| 116 | + .catch((e) => { | |
| 117 | + if (ctrl.signal.aborted) return; | |
| 118 | + setError(e instanceof Error ? e.message : 'unavailable'); | |
| 119 | + setBusy(false); | |
| 120 | + }); | |
| 121 | + }, 200); | |
| 122 | + return () => { | |
| 123 | + clearTimeout(t); | |
| 124 | + ctrl.abort(); | |
| 125 | + }; | |
| 126 | + }, [costQs, s.tab]); | |
| 127 | + | |
| 128 | + const ctxQs = useMemo(() => { | |
| 129 | + const t = Math.max(1, Math.round(num(s.tokens) ?? 0)); | |
| 130 | + const p = new URLSearchParams({ tokens: String(t), limit: '60' }); | |
| 131 | + if (s.model) p.set('model', s.model); | |
| 132 | + return p.toString(); | |
| 133 | + }, [s.tokens, s.model]); | |
| 134 | + useEffect(() => { | |
| 135 | + if (s.tab !== 'context') return; | |
| 136 | + const ctrl = new AbortController(); | |
| 137 | + setBusy(true); | |
| 138 | + setError(null); | |
| 139 | + const t = setTimeout(() => { | |
| 140 | + clientIntel | |
| 141 | + .costContext(ctxQs, ctrl.signal) | |
| 142 | + .then((r) => { | |
| 143 | + setCtx(r); | |
| 144 | + setBusy(false); | |
| 145 | + }) | |
| 146 | + .catch((e) => { | |
| 147 | + if (ctrl.signal.aborted) return; | |
| 148 | + setError(e instanceof Error ? e.message : 'unavailable'); | |
| 149 | + setBusy(false); | |
| 150 | + }); | |
| 151 | + }, 200); | |
| 152 | + return () => { | |
| 153 | + clearTimeout(t); | |
| 154 | + ctrl.abort(); | |
| 155 | + }; | |
| 156 | + }, [ctxQs, s.tab]); | |
| 157 | + | |
| 158 | + const set = useCallback(<K extends keyof State>(k: K, v: State[K]) => setS((p) => ({ ...p, [k]: v })), []); | |
| 159 | + const providers = useMemo(() => { | |
| 160 | + const m = new Map<string, string>(); | |
| 161 | + for (const it of cost?.items ?? []) m.set(it.deployment.provider.slug, it.deployment.provider.name); | |
| 162 | + return [...m.entries()]; | |
| 163 | + }, [cost]); | |
| 164 | + const cheapest = useMemo(() => { | |
| 165 | + const vals = (cost?.items ?? []).map((it) => num(it.cost.monthly)).filter((v): v is number => v !== null); | |
| 166 | + return vals.length ? Math.min(...vals) : null; | |
| 167 | + }, [cost]); | |
| 168 | + | |
| 169 | + const tabBtn = (id: State['tab'], label: string) => ( | |
| 170 | + <button type="button" role="tab" aria-selected={s.tab === id} onClick={() => set('tab', id)} className={cn('-mb-px flex h-11 items-center border-b-2 px-3 text-sm whitespace-nowrap', s.tab === id ? 'border-ink font-medium text-ink' : 'border-transparent text-ink-2 hover:text-ink')} data-calc-tab={id}> | |
| 171 | + {label} | |
| 172 | + </button> | |
| 173 | + ); | |
| 174 | + | |
| 175 | + return ( | |
| 176 | + <div data-calculator> | |
| 177 | + <div role="tablist" aria-label="Calculator mode" className="no-scrollbar -mx-4 flex overflow-x-auto border-b border-rule px-4 md:mx-0 md:px-0"> | |
| 178 | + {tabBtn('workload', 'Workload cost')} | |
| 179 | + {tabBtn('context', 'Context cost')} | |
| 180 | + </div> | |
| 181 | + | |
| 182 | + {/* --------------------------------------------------------------------------------------------------- inputs */} | |
| 183 | + <div className="grid gap-4 py-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]"> | |
| 184 | + <Field label="Model" className="lg:col-span-2" hint={s.model ? <span className="mono">{s.model}</span> : 'Any canonical model; suggestions from the atlas.'}> | |
| 185 | + <ModelPicker value={s.model} label={s.modelName || null} onSelect={(slug, name) => setS((p) => ({ ...p, model: slug, modelName: name, provider: '' }))} /> | |
| 186 | + </Field> | |
| 187 | + {s.tab === 'workload' ? ( | |
| 188 | + <> | |
| 189 | + <Field label="Provider (optional)"> | |
| 190 | + <select value={s.provider} onChange={(e) => set('provider', e.target.value)} className={CTRL_LG} disabled={!providers.length && !s.provider}> | |
| 191 | + <option value="">All providers</option> | |
| 192 | + {providers.map(([slug, name]) => ( | |
| 193 | + <option key={slug} value={slug}> | |
| 194 | + {name} | |
| 195 | + </option> | |
| 196 | + ))} | |
| 197 | + {s.provider && !providers.some(([slug]) => slug === s.provider) && <option value={s.provider}>{s.provider}</option>} | |
| 198 | + </select> | |
| 199 | + </Field> | |
| 200 | + <Field label="Requests / day"> | |
| 201 | + <input inputMode="numeric" value={s.requests_per_day} onChange={(e) => set('requests_per_day', e.target.value.replace(/[^\d.]/g, ''))} className={CTRL_LG} /> | |
| 202 | + </Field> | |
| 203 | + <Field label="Input tokens / request"> | |
| 204 | + <input inputMode="numeric" value={s.input_tokens} onChange={(e) => set('input_tokens', e.target.value.replace(/[^\d]/g, ''))} className={CTRL_LG} /> | |
| 205 | + </Field> | |
| 206 | + <Field label="Output tokens / request"> | |
| 207 | + <input inputMode="numeric" value={s.output_tokens} onChange={(e) => set('output_tokens', e.target.value.replace(/[^\d]/g, ''))} className={CTRL_LG} /> | |
| 208 | + </Field> | |
| 209 | + <Field label="Cached input share" hint="Share of input tokens served from a prompt cache (used only when the provider publishes a cached price)."> | |
| 210 | + <span className="flex items-center gap-3"> | |
| 211 | + <input type="range" min={0} max={100} step={5} value={s.cached} onChange={(e) => set('cached', e.target.value)} className="h-11 w-full accent-[var(--accent)]" aria-label="Cached input share (%)" /> | |
| 212 | + <span className="tnum w-12 shrink-0 text-right text-sm text-ink">{s.cached}%</span> | |
| 213 | + </span> | |
| 214 | + </Field> | |
| 215 | + <Field label="Batch API"> | |
| 216 | + <span className="flex h-11 items-center"> | |
| 217 | + <label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-sm text-ink-2"> | |
| 218 | + <input type="checkbox" checked={s.batch} onChange={(e) => set('batch', e.target.checked)} className="size-4 accent-[var(--accent)]" /> Use batch prices when published | |
| 219 | + </label> | |
| 220 | + </span> | |
| 221 | + </Field> | |
| 222 | + </> | |
| 223 | + ) : ( | |
| 224 | + <Field label="Context to fill (tokens)" className="lg:col-span-2" hint="How much does one fully populated context cost, at the input price? Presets or any number."> | |
| 225 | + <span className="flex flex-wrap items-center gap-1.5"> | |
| 226 | + {PRESETS_CTX.map((p) => ( | |
| 227 | + <button key={p.value} type="button" onClick={() => set('tokens', String(p.value))} aria-pressed={num(s.tokens) === p.value} className={cn('h-11 border px-3 text-sm font-medium', num(s.tokens) === p.value ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 228 | + {p.label} | |
| 229 | + </button> | |
| 230 | + ))} | |
| 231 | + <input inputMode="numeric" value={s.tokens} onChange={(e) => set('tokens', e.target.value.replace(/[^\d]/g, ''))} className={cn(CTRL_LG, 'w-40 flex-none')} aria-label="Custom token count" /> | |
| 232 | + </span> | |
| 233 | + </Field> | |
| 234 | + )} | |
| 235 | + </div> | |
| 236 | + | |
| 237 | + {/* --------------------------------------------------------------------------------------------------- results */} | |
| 238 | + {s.tab === 'workload' ? ( | |
| 239 | + !s.model ? ( | |
| 240 | + <p className="border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Pick a model to price this workload across every provider that currently serves it.</p> | |
| 241 | + ) : error ? ( | |
| 242 | + <p className="border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Cost unavailable ({error}). The slug must be a canonical model — try the suggestions.</p> | |
| 243 | + ) : !cost ? ( | |
| 244 | + <p className="py-8 text-center text-sm text-ink-3" aria-busy="true"> | |
| 245 | + Computing… | |
| 246 | + </p> | |
| 247 | + ) : ( | |
| 248 | + <div aria-busy={busy}> | |
| 249 | + <p className="tnum mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-ink-2"> | |
| 250 | + {cost.model && <EntityLink e={cost.model} className="text-[15px] font-medium" />} | |
| 251 | + <span> | |
| 252 | + {fmtInt(cost.inputs.input_tokens)} in + {fmtInt(cost.inputs.output_tokens)} out tokens × {fmtInt(cost.inputs.requests_per_day)} req/day · cached {Math.round((num(cost.inputs.cached_share) ?? 0) * 100)}% · batch {cost.inputs.batch ? 'on' : 'off'} | |
| 253 | + </span> | |
| 254 | + <span className="text-ink-3">{fmtInt(cost.total)} current deployment{cost.total === 1 ? '' : 's'}</span> | |
| 255 | + </p> | |
| 256 | + <DataTable scroll compact> | |
| 257 | + <thead> | |
| 258 | + <tr> | |
| 259 | + <Th>Provider</Th> | |
| 260 | + <Th num>Eff. input / 1M</Th> | |
| 261 | + <Th num>Eff. output / 1M</Th> | |
| 262 | + <Th num>Per request</Th> | |
| 263 | + <Th num>Daily</Th> | |
| 264 | + <Th num>Monthly</Th> | |
| 265 | + <Th num>Annual</Th> | |
| 266 | + <Th>Price rows used</Th> | |
| 267 | + <Th>Source</Th> | |
| 268 | + </tr> | |
| 269 | + </thead> | |
| 270 | + <tbody> | |
| 271 | + {cost.items.length === 0 && <EmptyRow cols={9}>No current deployment for this model{s.provider ? ' at this provider' : ''}. Without a published price there is nothing to compute.</EmptyRow>} | |
| 272 | + {cost.items.map((it) => { | |
| 273 | + const d = it.deployment; | |
| 274 | + const c = it.cost; | |
| 275 | + const best = cheapest !== null && num(c.monthly) === cheapest && cost.items.length > 1; | |
| 276 | + return ( | |
| 277 | + <tr key={d.id} className={best ? 'bg-accent-2-soft/40' : undefined} data-cost-row> | |
| 278 | + <Td primary> | |
| 279 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 280 | + <EntityLink e={d.provider} /> | |
| 281 | + {best && <Chip tone="accent">cheapest here</Chip>} | |
| 282 | + {d.status !== 'active' && <Chip>{d.status}</Chip>} | |
| 283 | + </span> | |
| 284 | + {d.provider_model_id && <span className="mono block text-[11px] text-ink-3">{d.provider_model_id}</span>} | |
| 285 | + {c.notes.length > 0 && ( | |
| 286 | + <ul className="mt-0.5 space-y-0.5 text-[11px] text-ink-3"> | |
| 287 | + {c.notes.map((n) => ( | |
| 288 | + <li key={n}>{n}</li> | |
| 289 | + ))} | |
| 290 | + </ul> | |
| 291 | + )} | |
| 292 | + </Td> | |
| 293 | + <Td num label="Eff. input" className="tnum text-accent-2">{fmtUsdPerM(c.effective_input_per_mtok)}</Td> | |
| 294 | + <Td num label="Eff. output" className="tnum text-accent-2">{fmtUsdPerM(c.effective_output_per_mtok)}</Td> | |
| 295 | + <Td num label="Per request" className="tnum">{fmtMoney(c.per_request)}</Td> | |
| 296 | + <Td num label="Daily" className="tnum">{fmtMoney(c.daily)}</Td> | |
| 297 | + <Td num label="Monthly" className={cn('tnum', best && 'font-semibold text-accent-2')}>{fmtMoney(c.monthly)}</Td> | |
| 298 | + <Td num label="Annual" className="tnum">{fmtMoney(c.annual)}</Td> | |
| 299 | + <Td label="Price rows used" className="tnum text-xs text-ink-2"> | |
| 300 | + in {fmtUsdPerM(d.prices.input)} · out {fmtUsdPerM(d.prices.output)} | |
| 301 | + {num(d.prices.cached_input) !== null && <> · cached {fmtUsdPerM(d.prices.cached_input)}</>} | |
| 302 | + {num(d.prices.batch_input) !== null && <> · batch in {fmtUsdPerM(d.prices.batch_input)}</>} | |
| 303 | + {num(d.prices.batch_output) !== null && <> · batch out {fmtUsdPerM(d.prices.batch_output)}</>} | |
| 304 | + {num(d.prices.per_request) !== null && <> · fee {fmtMoney(d.prices.per_request)}/req</>} | |
| 305 | + {num(d.context_length) !== null && <span className="block text-ink-3">context {fmtTokens(d.context_length)} · observed {fmtAgo(d.observed_at)}</span>} | |
| 306 | + </Td> | |
| 307 | + <Td label="Source"> | |
| 308 | + {d.source_url ? ( | |
| 309 | + <a href={d.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-ink-2 hover:text-accent"> | |
| 310 | + {hostOf(d.source_url) ?? 'source'} <span className="mono">T{d.tier}</span> <ExternalLink className="size-3" aria-hidden /> | |
| 311 | + </a> | |
| 312 | + ) : ( | |
| 313 | + <span className="text-xs text-ink-3">—</span> | |
| 314 | + )} | |
| 315 | + </Td> | |
| 316 | + </tr> | |
| 317 | + ); | |
| 318 | + })} | |
| 319 | + </tbody> | |
| 320 | + </DataTable> | |
| 321 | + <Note className="mt-3"> | |
| 322 | + Bold = lowest monthly cost <em>for this workload</em> among the deployments listed — not a verdict about the model or the provider. Prices are the current published rows; cached/batch prices apply only when published (otherwise the standard price is used and a note says so). | |
| 323 | + </Note> | |
| 324 | + <Methodology text={cost.methodology} /> | |
| 325 | + {cost.note && <Note className="mt-1">{cost.note}</Note>} | |
| 326 | + </div> | |
| 327 | + ) | |
| 328 | + ) : error ? ( | |
| 329 | + <p className="border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Context cost unavailable ({error}).</p> | |
| 330 | + ) : !ctx ? ( | |
| 331 | + <p className="py-8 text-center text-sm text-ink-3" aria-busy="true"> | |
| 332 | + Computing… | |
| 333 | + </p> | |
| 334 | + ) : ( | |
| 335 | + <div aria-busy={busy}> | |
| 336 | + <h2 className="mb-3 text-base font-semibold tracking-tight md:text-lg"> | |
| 337 | + How much does a fully populated {fmtTokens(ctx.tokens)}-token context cost? <span className="tnum text-sm font-normal text-ink-3">{fmtInt(ctx.total)} offers with context ≥ {fmtTokens(ctx.tokens)}</span> | |
| 338 | + </h2> | |
| 339 | + <DataTable scroll compact> | |
| 340 | + <thead> | |
| 341 | + <tr> | |
| 342 | + <Th>Model</Th> | |
| 343 | + <Th>Provider</Th> | |
| 344 | + <Th num>Context</Th> | |
| 345 | + <Th num>Input / 1M</Th> | |
| 346 | + <Th num>Cost per fill</Th> | |
| 347 | + <Th>Source</Th> | |
| 348 | + </tr> | |
| 349 | + </thead> | |
| 350 | + <tbody> | |
| 351 | + {ctx.items.length === 0 && <EmptyRow cols={6}>No current offer advertises a context window of at least {fmtTokens(ctx.tokens)} tokens{s.model ? ' for this model' : ''}.</EmptyRow>} | |
| 352 | + {ctx.items.map((it, i) => { | |
| 353 | + const d = it.deployment; | |
| 354 | + return ( | |
| 355 | + <tr key={`${d.id}-${i}`} data-context-row> | |
| 356 | + <Td primary> | |
| 357 | + <EntityLink e={d.model} /> | |
| 358 | + {d.model.organization && <span className="ml-2 text-xs text-ink-3">{d.model.organization.name}</span>} | |
| 359 | + </Td> | |
| 360 | + <Td label="Provider" className="text-ink-2"> | |
| 361 | + <Link href={routes.entity(d.provider)} className="hover:text-accent"> | |
| 362 | + {d.provider.name} | |
| 363 | + </Link> | |
| 364 | + </Td> | |
| 365 | + <Td num label="Context" className="tnum"> | |
| 366 | + {fmtTokens(it.context_length)} <span className="text-[10px] text-ink-3">{it.context_source}</span> | |
| 367 | + </Td> | |
| 368 | + <Td num label="Input / 1M" className="tnum text-accent-2">{fmtUsdPerM(d.prices.input)}</Td> | |
| 369 | + <Td num label="Cost per fill" className={cn('tnum', i === 0 && ctx.items.length > 1 && 'font-semibold text-accent-2')}>{fmtMoney(it.cost_usd)}</Td> | |
| 370 | + <Td label="Source"> | |
| 371 | + {d.source_url ? ( | |
| 372 | + <a href={d.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-ink-2 hover:text-accent"> | |
| 373 | + {hostOf(d.source_url) ?? 'source'} <span className="mono">T{d.tier}</span> <ExternalLink className="size-3" aria-hidden /> | |
| 374 | + </a> | |
| 375 | + ) : ( | |
| 376 | + <span className="text-xs text-ink-3">—</span> | |
| 377 | + )} | |
| 378 | + </Td> | |
| 379 | + </tr> | |
| 380 | + ); | |
| 381 | + })} | |
| 382 | + </tbody> | |
| 383 | + </DataTable> | |
| 384 | + <Note className="mt-3">Cheapest first (API order). Bold marks the lowest cost among the offers shown — a fact about the input price, not a quality judgement.</Note> | |
| 385 | + <Methodology text={ctx.methodology} /> | |
| 386 | + </div> | |
| 387 | + )} | |
| 388 | + </div> | |
| 389 | + ); | |
| 390 | +} | |
added
apps/web/src/components/intelligence/client-charts.tsx
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useEffect, useState } from 'react'; | |
| 3 | +import { InteractiveLineChart, Legend, ScatterChart, type ScatterPoint, type Series } from '@/components/charts'; | |
| 4 | +import { fmtDate, fmtInt, fmtScore, fmtUsdPerM } from '@/lib/format'; | |
| 5 | + | |
| 6 | +/* | |
| 7 | + Client wrappers that own their formatter functions: server pages cannot pass functions to client components, so the | |
| 8 | + formatting choices (money, score, GB, GB/s, dates) live here and pages pass data only. | |
| 9 | +*/ | |
| 10 | + | |
| 11 | +/** Quality vs cheapest output price (log x), Pareto set highlighted and joined by a dashed line. */ | |
| 12 | +export function EfficiencyScatter({ points, frontier, highlight, yLabel, height = 380 }: { points: ScatterPoint[]; frontier: { x: number; y: number }[]; highlight: string[]; yLabel: string; height?: number }) { | |
| 13 | + // Rendered after mount only: the log-scale pixel positions differ in the last float digits between Node and the browser, | |
| 14 | + // which React reports as a hydration mismatch. The Pareto list below the chart is server-rendered for crawlers. | |
| 15 | + const [mounted, setMounted] = useState(false); | |
| 16 | + useEffect(() => setMounted(true), []); | |
| 17 | + if (!mounted) return <div style={{ aspectRatio: `720 / ${height}` }} className="w-full animate-pulse bg-surface-2" aria-hidden />; | |
| 18 | + return <ScatterChart points={points} xScale="log" yScale="linear" xLabel="Cheapest output price, USD / 1M tokens (log)" yLabel={yLabel} xFormat={(v) => fmtUsdPerM(v)} yFormat={(v) => fmtScore(v)} frontier={frontier} highlight={highlight} height={height} labelTop={0} />; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** Step chart of a hardware figure by release date (memory in GB or bandwidth in GB/s), one series per manufacturer. */ | |
| 22 | +export function HardwareStepChart({ series, unit, yLabel, height = 280 }: { series: Series[]; unit: 'GB' | 'GB/s'; yLabel: string; height?: number }) { | |
| 23 | + return ( | |
| 24 | + <> | |
| 25 | + <InteractiveLineChart series={series} height={height} step showDots yFormat={(v) => `${fmtInt(v)} ${unit}`} yLabel={yLabel} xFormat={(x) => fmtDate(new Date(x).toISOString().slice(0, 10))} /> | |
| 26 | + <Legend series={series} className="mt-2" /> | |
| 27 | + </> | |
| 28 | + ); | |
| 29 | +} | |
added
apps/web/src/components/intelligence/expand-price-row.tsx
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ChevronDown, ChevronUp } from 'lucide-react'; | |
| 3 | +import { type ReactNode, useEffect, useState } from 'react'; | |
| 4 | +import { Sparkline, StepChart, stepPoints, type Series } from '@/components/charts'; | |
| 5 | +import { clientIntel } from '@/lib/client-api'; | |
| 6 | +import { cn } from '@/lib/cn'; | |
| 7 | +import { fmtDate, fmtUsdPerM, num } from '@/lib/format'; | |
| 8 | +import type { Price } from '@/lib/types'; | |
| 9 | + | |
| 10 | +/* | |
| 11 | + Expandable offer row: the server renders the cells (children); the last cell holds a toggle. On first expansion the browser | |
| 12 | + fetches `/prices/history?model=&provider=` and renders input/output sparklines (offers table) or a step chart (provider page). | |
| 13 | + Rows are keyed by model × provider; the fetch is lazy and cached in component state. | |
| 14 | +*/ | |
| 15 | +export function ExpandableOfferRow({ model, provider, children, colSpan, mode = 'spark', className }: { model: string; provider: string; children: ReactNode; colSpan: number; mode?: 'spark' | 'step'; className?: string }) { | |
| 16 | + const [open, setOpen] = useState(false); | |
| 17 | + const [rows, setRows] = useState<Price[] | null>(null); | |
| 18 | + const [error, setError] = useState<string | null>(null); | |
| 19 | + useEffect(() => { | |
| 20 | + if (!open || rows || error) return; | |
| 21 | + const ctrl = new AbortController(); | |
| 22 | + clientIntel | |
| 23 | + .priceHistory(model, provider, ctrl.signal) | |
| 24 | + .then((r) => setRows(r.items.slice().sort((a, b) => a.valid_from.localeCompare(b.valid_from)))) | |
| 25 | + .catch((e) => { | |
| 26 | + if (!ctrl.signal.aborted) setError(e instanceof Error ? e.message : 'unavailable'); | |
| 27 | + }); | |
| 28 | + return () => ctrl.abort(); | |
| 29 | + }, [open, rows, error, model, provider]); | |
| 30 | + return ( | |
| 31 | + <> | |
| 32 | + <tr className={className} data-offer-row> | |
| 33 | + {children} | |
| 34 | + <td className="text-right"> | |
| 35 | + <button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} aria-label={open ? 'Hide price history' : 'Show price history'} className="inline-flex h-7 items-center gap-1 border border-rule px-1.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink" data-expand-history> | |
| 36 | + {open ? <ChevronUp className="size-3" aria-hidden /> : <ChevronDown className="size-3" aria-hidden />} History | |
| 37 | + </button> | |
| 38 | + </td> | |
| 39 | + </tr> | |
| 40 | + {open && ( | |
| 41 | + <tr className="bg-surface-2/40" data-offer-history> | |
| 42 | + <td colSpan={colSpan + 1} className="wide !py-3"> | |
| 43 | + {error ? ( | |
| 44 | + <p className="text-xs text-ink-3">Price history unavailable ({error}).</p> | |
| 45 | + ) : !rows ? ( | |
| 46 | + <p className="text-xs text-ink-3" aria-busy="true"> | |
| 47 | + Loading history… | |
| 48 | + </p> | |
| 49 | + ) : ( | |
| 50 | + <History rows={rows} mode={mode} /> | |
| 51 | + )} | |
| 52 | + </td> | |
| 53 | + </tr> | |
| 54 | + )} | |
| 55 | + </> | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 59 | +function History({ rows, mode }: { rows: Price[]; mode: 'spark' | 'step' }) { | |
| 60 | + const ins = rows.map((r) => num(r.input_per_mtok)).filter((v): v is number => v !== null); | |
| 61 | + const outs = rows.map((r) => num(r.output_per_mtok)).filter((v): v is number => v !== null); | |
| 62 | + if (!rows.length) return <p className="text-xs text-ink-3">No history rows for this model × provider.</p>; | |
| 63 | + const first = rows[0] as Price; | |
| 64 | + const last = rows[rows.length - 1] as Price; | |
| 65 | + const distinct = new Set(rows.map((r) => `${r.input_per_mtok}|${r.output_per_mtok}`)).size; | |
| 66 | + if (mode === 'step' && distinct >= 2) { | |
| 67 | + const now = new Date().toISOString(); | |
| 68 | + const build = (field: 'input_per_mtok' | 'output_per_mtok', name: string, color: string): Series => ({ name, color, points: stepPoints([...rows.map((r) => ({ at: r.valid_from, value: num(r[field]) })), ...(last.valid_to ? [] : [{ at: now, value: num(last[field]) }])]) }); | |
| 69 | + const series = [build('input_per_mtok', 'Input', 'var(--series-1)'), build('output_per_mtok', 'Output', 'var(--series-2)')].filter((s) => s.points.length > 1); | |
| 70 | + return ( | |
| 71 | + <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_16rem] md:items-start"> | |
| 72 | + <StepChart series={series} height={160} yFormat={(v) => fmtUsdPerM(v)} yLabel="USD per 1M tokens" /> | |
| 73 | + <Facts rows={rows} first={first} last={last} distinct={distinct} /> | |
| 74 | + </div> | |
| 75 | + ); | |
| 76 | + } | |
| 77 | + return ( | |
| 78 | + <div className="flex flex-wrap items-center gap-x-6 gap-y-2"> | |
| 79 | + <span className="flex items-center gap-2 text-xs text-ink-3"> | |
| 80 | + Input <Sparkline values={ins} width={120} height={26} stroke="var(--series-1)" variant="trend" invert format={(v) => fmtUsdPerM(v)} title="Input price history" /> | |
| 81 | + </span> | |
| 82 | + <span className="flex items-center gap-2 text-xs text-ink-3"> | |
| 83 | + Output <Sparkline values={outs} width={120} height={26} stroke="var(--series-2)" variant="trend" invert format={(v) => fmtUsdPerM(v)} title="Output price history" /> | |
| 84 | + </span> | |
| 85 | + <Facts rows={rows} first={first} last={last} distinct={distinct} inline /> | |
| 86 | + </div> | |
| 87 | + ); | |
| 88 | +} | |
| 89 | + | |
| 90 | +function Facts({ rows, first, last, distinct, inline = false }: { rows: Price[]; first: Price; last: Price; distinct: number; inline?: boolean }) { | |
| 91 | + return ( | |
| 92 | + <p className={cn('tnum text-xs text-ink-3', !inline && 'leading-relaxed')}> | |
| 93 | + {rows.length} observation{rows.length === 1 ? '' : 's'} · {distinct} distinct price{distinct === 1 ? '' : 's'} · first {fmtDate(first.valid_from)} · latest {fmtDate(last.observed_at)} | |
| 94 | + {distinct < 2 && <span className="block">No change recorded yet — a sparkline needs two distinct prices.</span>} | |
| 95 | + </p> | |
| 96 | + ); | |
| 97 | +} | |
added
apps/web/src/components/intelligence/finder-form.tsx
+132 −0
@@ -0,0 +1,132 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { APPLE_PRESETS, NVIDIA_PRESETS, QUANTS } from '@/components/hardware/fit-form'; | |
| 3 | +import { BTN_GHOST, CTRL, Field } from './bits'; | |
| 4 | + | |
| 5 | +/* GET form for /find-a-model — "What do you need?" Every field maps 1:1 to a `/find-a-model` parameter. */ | |
| 6 | +export const FINDER_USE_CASES = [ | |
| 7 | + { value: '', label: 'Any' }, | |
| 8 | + { value: 'chat', label: 'Chat / assistant' }, | |
| 9 | + { value: 'coding', label: 'Coding' }, | |
| 10 | + { value: 'reasoning', label: 'Reasoning / math' }, | |
| 11 | + { value: 'agentic', label: 'Agentic / tool use' }, | |
| 12 | + { value: 'long_context', label: 'Long context' }, | |
| 13 | + { value: 'vision', label: 'Vision' }, | |
| 14 | + { value: 'embeddings', label: 'Embeddings' }, | |
| 15 | + { value: 'low_cost', label: 'Low cost' }, | |
| 16 | + { value: 'local', label: 'Runs locally' }, | |
| 17 | +]; | |
| 18 | +export const MODALITIES = ['text', 'image', 'audio', 'video', 'embedding']; | |
| 19 | +export type FinderInputs = { useCase: string; deployment: 'any' | 'local' | 'api'; memory: number | null; quant: string; contextMin: number | null; license: 'any' | 'commercial'; openness: string; maxIn: number | null; maxOut: number | null; modalities: string[] }; | |
| 20 | + | |
| 21 | +export function FinderForm({ v, className }: { v: FinderInputs; className?: string }) { | |
| 22 | + const presets = [...APPLE_PRESETS, ...NVIDIA_PRESETS]; | |
| 23 | + const presetSelected = v.memory !== null && presets.includes(v.memory) ? String(v.memory) : ''; | |
| 24 | + const radio = (name: string, value: string, current: string, label: string) => ( | |
| 25 | + <label className="inline-flex min-h-11 cursor-pointer items-center lg:min-h-9 gap-1.5 border border-rule px-2.5 text-sm text-ink-2 has-[:checked]:border-ink has-[:checked]:bg-ink has-[:checked]:text-canvas"> | |
| 26 | + <input type="radio" name={name} value={value} defaultChecked={current === value} className="sr-only" /> {label} | |
| 27 | + </label> | |
| 28 | + ); | |
| 29 | + return ( | |
| 30 | + <form action="/find-a-model" method="get" className={`space-y-3 ${className ?? ''}`} data-finder-form> | |
| 31 | + <Field label="Use case"> | |
| 32 | + <select name="use_case" defaultValue={v.useCase} className={CTRL}> | |
| 33 | + {FINDER_USE_CASES.map((u) => ( | |
| 34 | + <option key={u.value} value={u.value}> | |
| 35 | + {u.label} | |
| 36 | + </option> | |
| 37 | + ))} | |
| 38 | + </select> | |
| 39 | + </Field> | |
| 40 | + <div> | |
| 41 | + <span className="eyebrow block pb-1">Deployment</span> | |
| 42 | + <div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="Deployment"> | |
| 43 | + {radio('deployment', 'any', v.deployment, 'Any')} | |
| 44 | + {radio('deployment', 'local', v.deployment, 'Local')} | |
| 45 | + {radio('deployment', 'api', v.deployment, 'API')} | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + <Field label="Memory (local)" hint="Used for the estimated fit when deployment is local."> | |
| 49 | + <select name="memory_gb" defaultValue={presetSelected} className={CTRL}> | |
| 50 | + <option value="">Not specified</option> | |
| 51 | + <optgroup label="Apple silicon"> | |
| 52 | + {APPLE_PRESETS.map((g) => ( | |
| 53 | + <option key={`a${g}`} value={g}> | |
| 54 | + {g} GB | |
| 55 | + </option> | |
| 56 | + ))} | |
| 57 | + </optgroup> | |
| 58 | + <optgroup label="NVIDIA"> | |
| 59 | + {NVIDIA_PRESETS.map((g) => ( | |
| 60 | + <option key={`n${g}`} value={g}> | |
| 61 | + {g} GB | |
| 62 | + </option> | |
| 63 | + ))} | |
| 64 | + </optgroup> | |
| 65 | + </select> | |
| 66 | + </Field> | |
| 67 | + <Field label="…or any memory (GB)"> | |
| 68 | + <input name="memory_custom" inputMode="decimal" defaultValue={presetSelected ? '' : v.memory ?? ''} placeholder="e.g. 20" className={CTRL} /> | |
| 69 | + </Field> | |
| 70 | + <Field label="Quantization (local)"> | |
| 71 | + <select name="quant" defaultValue={v.quant} className={CTRL}> | |
| 72 | + {QUANTS.map((q) => ( | |
| 73 | + <option key={q.value} value={q.value}> | |
| 74 | + {q.label} | |
| 75 | + </option> | |
| 76 | + ))} | |
| 77 | + </select> | |
| 78 | + </Field> | |
| 79 | + <Field label="Context ≥ (tokens)"> | |
| 80 | + <select name="context_min" defaultValue={v.contextMin ? String(v.contextMin) : ''} className={CTRL}> | |
| 81 | + <option value="">Any</option> | |
| 82 | + <option value="32768">32K</option> | |
| 83 | + <option value="128000">128K</option> | |
| 84 | + <option value="200000">200K</option> | |
| 85 | + <option value="1000000">1M</option> | |
| 86 | + </select> | |
| 87 | + </Field> | |
| 88 | + <div> | |
| 89 | + <span className="eyebrow block pb-1">Licence</span> | |
| 90 | + <div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="Licence"> | |
| 91 | + {radio('license', 'any', v.license, 'Any')} | |
| 92 | + {radio('license', 'commercial', v.license, 'Commercial use allowed')} | |
| 93 | + </div> | |
| 94 | + </div> | |
| 95 | + <Field label="Openness"> | |
| 96 | + <select name="openness" defaultValue={v.openness} className={CTRL}> | |
| 97 | + <option value="">Any</option> | |
| 98 | + <option value="open-source">Open source</option> | |
| 99 | + <option value="open-weights">Open weights</option> | |
| 100 | + <option value="restricted-weights">Restricted weights</option> | |
| 101 | + <option value="proprietary">Proprietary</option> | |
| 102 | + </select> | |
| 103 | + </Field> | |
| 104 | + <div className="grid grid-cols-2 gap-2"> | |
| 105 | + <Field label="Max input $/1M"> | |
| 106 | + <input name="max_input_price" inputMode="decimal" defaultValue={v.maxIn ?? ''} placeholder="e.g. 1" className={CTRL} /> | |
| 107 | + </Field> | |
| 108 | + <Field label="Max output $/1M"> | |
| 109 | + <input name="max_output_price" inputMode="decimal" defaultValue={v.maxOut ?? ''} placeholder="e.g. 5" className={CTRL} /> | |
| 110 | + </Field> | |
| 111 | + </div> | |
| 112 | + <div> | |
| 113 | + <span className="eyebrow block pb-1">Modalities (all required)</span> | |
| 114 | + <div className="flex flex-wrap gap-1.5"> | |
| 115 | + {MODALITIES.map((m) => ( | |
| 116 | + <label key={m} className="inline-flex min-h-11 cursor-pointer items-center lg:min-h-9 gap-1.5 border border-rule px-2.5 text-sm text-ink-2 has-[:checked]:border-ink has-[:checked]:bg-ink has-[:checked]:text-canvas"> | |
| 117 | + <input type="checkbox" name="mod" value={m} defaultChecked={v.modalities.includes(m)} className="sr-only" /> {m} | |
| 118 | + </label> | |
| 119 | + ))} | |
| 120 | + </div> | |
| 121 | + </div> | |
| 122 | + <div className="flex gap-2"> | |
| 123 | + <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink lg:h-10 px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 124 | + Find models | |
| 125 | + </button> | |
| 126 | + <Link href="/find-a-model" className={BTN_GHOST}> | |
| 127 | + Reset | |
| 128 | + </Link> | |
| 129 | + </div> | |
| 130 | + </form> | |
| 131 | + ); | |
| 132 | +} | |
added
apps/web/src/components/intelligence/listing-table.tsx
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +import type { ReactNode } from 'react'; | |
| 2 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 3 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 4 | +import { FilterBar, type FilterField } from '@/components/listing/filters'; | |
| 5 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 6 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { safe } from '@/lib/api'; | |
| 10 | +import { fmtInt } from '@/lib/format'; | |
| 11 | +import type { EntitySummary, Page } from '@/lib/types'; | |
| 12 | + | |
| 13 | +/* | |
| 14 | + Intelligence listing: the typed table used by /agents /tools /datasets /frameworks. Same URL contract as `TypedListing` | |
| 15 | + (q · org · sort · offset, GET FilterBar) but with an optional per-page `enrich(items)` step that fetches extra facts for the | |
| 16 | + rows shown (relations, metric history…) and passes them to the column renderers, plus honest empty states: | |
| 17 | + no filters → "No X recorded yet — connectors: …"; filters → "No X match these filters". | |
| 18 | +*/ | |
| 19 | +export type IntelColumn<X> = { | |
| 20 | + key: string; | |
| 21 | + label: string; | |
| 22 | + num?: boolean; | |
| 23 | + primary?: boolean; | |
| 24 | + wide?: boolean; | |
| 25 | + hideStack?: boolean; | |
| 26 | + className?: string; | |
| 27 | + render: (e: EntitySummary, extra: X | undefined) => ReactNode; | |
| 28 | +}; | |
| 29 | + | |
| 30 | +const LIMIT = 40; | |
| 31 | +const PARAM_KEYS = ['q', 'org', 'sort', 'order', 'category', 'kind', 'since', 'until', 'manufacturer', 'offset']; | |
| 32 | + | |
| 33 | +export async function IntelListing<X = never>({ | |
| 34 | + title, | |
| 35 | + eyebrow, | |
| 36 | + lede, | |
| 37 | + basePath, | |
| 38 | + searchParams, | |
| 39 | + fetch, | |
| 40 | + columns, | |
| 41 | + sorts, | |
| 42 | + filters = [], | |
| 43 | + enrich, | |
| 44 | + connectors, | |
| 45 | + emptyHint, | |
| 46 | + note, | |
| 47 | + compare = false, | |
| 48 | + headerAside, | |
| 49 | + children, | |
| 50 | + caption, | |
| 51 | +}: { | |
| 52 | + title: string; | |
| 53 | + eyebrow: string; | |
| 54 | + lede?: ReactNode; | |
| 55 | + basePath: string; | |
| 56 | + searchParams: Record<string, string | undefined>; | |
| 57 | + fetch: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>; | |
| 58 | + columns: IntelColumn<X>[]; | |
| 59 | + sorts: { value: string; label: string }[]; | |
| 60 | + filters?: FilterField[]; | |
| 61 | + /** Fetch extra facts for the rows on this page (keyed by slug). Failures per row are tolerated (undefined). */ | |
| 62 | + enrich?: (items: EntitySummary[]) => Promise<Map<string, X>>; | |
| 63 | + /** Connector names that feed this type (for the honest empty state). */ | |
| 64 | + connectors?: string[]; | |
| 65 | + emptyHint?: ReactNode; | |
| 66 | + note?: ReactNode; | |
| 67 | + compare?: boolean; | |
| 68 | + headerAside?: ReactNode; | |
| 69 | + children?: ReactNode; | |
| 70 | + caption?: string; | |
| 71 | +}) { | |
| 72 | + const current: Record<string, string | undefined> = {}; | |
| 73 | + for (const k of PARAM_KEYS) if (searchParams[k]) current[k] = searchParams[k]; | |
| 74 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 75 | + const sort = current.sort ?? sorts[0]?.value; | |
| 76 | + const page = await safe(fetch({ ...current, sort, limit: LIMIT, offset })); | |
| 77 | + const extras = page && enrich && page.items.length ? await enrich(page.items).catch(() => new Map<string, X>()) : new Map<string, X>(); | |
| 78 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch); | |
| 79 | + const filtered = Object.keys(current).some((k) => !['sort', 'order', 'offset'].includes(k)); | |
| 80 | + const cols = columns.length + (compare ? 1 : 0); | |
| 81 | + const lower = title.toLowerCase(); | |
| 82 | + return ( | |
| 83 | + <Container wide> | |
| 84 | + <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page || headerAside ? <div className="flex flex-col items-start gap-2 md:items-end">{page && <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p>}{headerAside}</div> : undefined}> | |
| 85 | + <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...filters]} sort={{ value: sort, options: sorts }} /> | |
| 86 | + </PageHeader> | |
| 87 | + {children} | |
| 88 | + <div className="pb-16"> | |
| 89 | + {!page ? ( | |
| 90 | + <Unavailable what={title} /> | |
| 91 | + ) : page.items.length === 0 && !offset ? ( | |
| 92 | + filtered ? ( | |
| 93 | + <EmptyState title={`No ${lower} match these filters`}>{emptyHint ?? 'Remove a filter or search for another name.'}</EmptyState> | |
| 94 | + ) : ( | |
| 95 | + <EmptyState title={`No ${lower} recorded yet`} className="[&_p]:max-w-2xl [&_p]:mx-auto"> | |
| 96 | + {emptyHint} | |
| 97 | + {connectors && connectors.length > 0 && ( | |
| 98 | + <span className="mt-1 block"> | |
| 99 | + Connectors that will populate this type: <span className="mono text-ink-2">{connectors.join(' · ')}</span>. Nothing is listed until a source has been crawled. | |
| 100 | + </span> | |
| 101 | + )} | |
| 102 | + </EmptyState> | |
| 103 | + ) | |
| 104 | + ) : ( | |
| 105 | + <> | |
| 106 | + <div className="md:overflow-x-auto"> | |
| 107 | + <DataTable caption={caption ?? title}> | |
| 108 | + <thead> | |
| 109 | + <tr> | |
| 110 | + {columns.map((c) => ( | |
| 111 | + <Th key={c.key} num={c.num}> | |
| 112 | + {c.label} | |
| 113 | + </Th> | |
| 114 | + ))} | |
| 115 | + {compare && <Th className="w-24" aria-label="Compare" />} | |
| 116 | + </tr> | |
| 117 | + </thead> | |
| 118 | + <tbody> | |
| 119 | + {page.items.length === 0 && <EmptyRow cols={cols}>No rows on this page.</EmptyRow>} | |
| 120 | + {page.items.map((e) => ( | |
| 121 | + <tr key={e.id}> | |
| 122 | + {columns.map((c) => ( | |
| 123 | + <Td key={c.key} label={c.primary ? undefined : c.label} num={c.num} primary={c.primary} wide={c.wide} hideStack={c.hideStack} className={c.className}> | |
| 124 | + {c.render(e, extras.get(e.slug))} | |
| 125 | + </Td> | |
| 126 | + ))} | |
| 127 | + {compare && ( | |
| 128 | + <Td className="text-right"> | |
| 129 | + <CompareButton e={e} size="sm" /> | |
| 130 | + </Td> | |
| 131 | + )} | |
| 132 | + </tr> | |
| 133 | + ))} | |
| 134 | + </tbody> | |
| 135 | + </DataTable> | |
| 136 | + </div> | |
| 137 | + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 138 | + {note && <Note className="mt-3">{note}</Note>} | |
| 139 | + </> | |
| 140 | + )} | |
| 141 | + </div> | |
| 142 | + {compare && <CompareTrayBar />} | |
| 143 | + </Container> | |
| 144 | + ); | |
| 145 | +} | |
| 146 | + | |
| 147 | +export function Dash() { | |
| 148 | + return <span className="text-ink-3">—</span>; | |
| 149 | +} | |
| 150 | +export function str(v: unknown): string | null { | |
| 151 | + return typeof v === 'string' && v.trim() ? v : null; | |
| 152 | +} | |
| 153 | +export function list(v: unknown): string[] { | |
| 154 | + return Array.isArray(v) ? v.filter((x) => x !== null && x !== undefined).map(String) : []; | |
| 155 | +} | |
added
apps/web/src/components/intelligence/model-picker.tsx
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Search } from 'lucide-react'; | |
| 3 | +import { useEffect, useId, useRef, useState } from 'react'; | |
| 4 | +import { clientApi } from '@/lib/client-api'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import type { Suggestion } from '@/lib/types'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Model autocomplete on `/search/suggest` (type model), used by the calculator. Emits the chosen slug through `onSelect`; | |
| 10 | + * works as a plain text field when JS is slow (the slug can be typed). | |
| 11 | + */ | |
| 12 | +const DEFAULT_TYPES = ['model']; | |
| 13 | +export function ModelPicker({ value, label, onSelect, placeholder = 'Type a model name…', className, inputClassName, types = DEFAULT_TYPES }: { value: string; label?: string | null; onSelect: (slug: string, name: string) => void; placeholder?: string; className?: string; inputClassName?: string; types?: string[] }) { | |
| 14 | + const [q, setQ] = useState(label ?? value); | |
| 15 | + const [items, setItems] = useState<Suggestion[]>([]); | |
| 16 | + const [open, setOpen] = useState(false); | |
| 17 | + const [active, setActive] = useState(0); | |
| 18 | + const id = useId(); | |
| 19 | + const box = useRef<HTMLDivElement>(null); | |
| 20 | + useEffect(() => { | |
| 21 | + setQ(label ?? value); | |
| 22 | + }, [label, value]); | |
| 23 | + useEffect(() => { | |
| 24 | + if (!open || q.trim().length < 2 || q === label) { | |
| 25 | + setItems([]); | |
| 26 | + return; | |
| 27 | + } | |
| 28 | + const ctrl = new AbortController(); | |
| 29 | + const t = setTimeout(() => { | |
| 30 | + clientApi | |
| 31 | + .suggest(q.trim(), ctrl.signal) | |
| 32 | + .then((r) => setItems(r.items.filter((i) => types.includes(i.entity_type)).slice(0, 8))) | |
| 33 | + .catch(() => setItems([])); | |
| 34 | + }, 120); | |
| 35 | + return () => { | |
| 36 | + clearTimeout(t); | |
| 37 | + ctrl.abort(); | |
| 38 | + }; | |
| 39 | + }, [q, open, label, types]); | |
| 40 | + useEffect(() => { | |
| 41 | + const onDoc = (e: MouseEvent) => { | |
| 42 | + if (!box.current?.contains(e.target as Node)) setOpen(false); | |
| 43 | + }; | |
| 44 | + document.addEventListener('mousedown', onDoc); | |
| 45 | + return () => document.removeEventListener('mousedown', onDoc); | |
| 46 | + }, []); | |
| 47 | + const pick = (s: Suggestion) => { | |
| 48 | + onSelect(s.slug, s.name); | |
| 49 | + setQ(s.name); | |
| 50 | + setOpen(false); | |
| 51 | + }; | |
| 52 | + return ( | |
| 53 | + <div ref={box} className={cn('relative', className)}> | |
| 54 | + <div className="relative"> | |
| 55 | + <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-ink-3" aria-hidden /> | |
| 56 | + <input | |
| 57 | + value={q} | |
| 58 | + onChange={(e) => { | |
| 59 | + setQ(e.target.value); | |
| 60 | + setOpen(true); | |
| 61 | + setActive(0); | |
| 62 | + }} | |
| 63 | + onFocus={() => setOpen(true)} | |
| 64 | + onKeyDown={(e) => { | |
| 65 | + if (!open || !items.length) { | |
| 66 | + if (e.key === 'Enter' && q.trim() && !items.length) { | |
| 67 | + onSelect(q.trim().toLowerCase().replace(/\s+/g, '-'), q.trim()); | |
| 68 | + setOpen(false); | |
| 69 | + } | |
| 70 | + return; | |
| 71 | + } | |
| 72 | + if (e.key === 'ArrowDown') { | |
| 73 | + e.preventDefault(); | |
| 74 | + setActive((a) => (a + 1) % items.length); | |
| 75 | + } else if (e.key === 'ArrowUp') { | |
| 76 | + e.preventDefault(); | |
| 77 | + setActive((a) => (a - 1 + items.length) % items.length); | |
| 78 | + } else if (e.key === 'Enter') { | |
| 79 | + e.preventDefault(); | |
| 80 | + const s = items[active]; | |
| 81 | + if (s) pick(s); | |
| 82 | + } else if (e.key === 'Escape') setOpen(false); | |
| 83 | + }} | |
| 84 | + placeholder={placeholder} | |
| 85 | + role="combobox" | |
| 86 | + aria-expanded={open && items.length > 0} | |
| 87 | + aria-controls={`${id}-list`} | |
| 88 | + aria-autocomplete="list" | |
| 89 | + autoComplete="off" | |
| 90 | + className={cn('h-11 w-full border border-rule bg-surface pl-9 pr-2.5 text-[15px] text-ink focus:border-accent focus:outline-none', inputClassName)} | |
| 91 | + data-model-picker | |
| 92 | + /> | |
| 93 | + </div> | |
| 94 | + {open && items.length > 0 && ( | |
| 95 | + <ul id={`${id}-list`} role="listbox" className="panel absolute z-20 mt-1 max-h-72 w-full overflow-y-auto py-1 shadow-lg"> | |
| 96 | + {items.map((s, i) => ( | |
| 97 | + <li key={s.id} role="option" aria-selected={i === active}> | |
| 98 | + <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => pick(s)} onMouseEnter={() => setActive(i)} className={cn('flex min-h-11 w-full items-center justify-between gap-3 px-3 text-left text-sm', i === active ? 'bg-surface-2 text-ink' : 'text-ink-2 hover:bg-surface-2')} data-model-option> | |
| 99 | + <span className="truncate">{s.name}</span> | |
| 100 | + <span className="truncate text-xs text-ink-3">{s.organization_name ?? s.slug}</span> | |
| 101 | + </button> | |
| 102 | + </li> | |
| 103 | + ))} | |
| 104 | + </ul> | |
| 105 | + )} | |
| 106 | + </div> | |
| 107 | + ); | |
| 108 | +} | |
added
apps/web/src/components/intelligence/price-index-chart.tsx
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useMemo, useState } from 'react'; | |
| 3 | +import { InteractiveLineChart, Legend, type Series } from '@/components/charts'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | +import { fmtInt, fmtUsdPerM, num } from '@/lib/format'; | |
| 6 | +import type { PriceIndexPointIntel } from '@/lib/types'; | |
| 7 | + | |
| 8 | +/* | |
| 9 | + AI Price Index chart: five daily-median series with a linear/log toggle (mirrored into `?scale=` with replaceState) and a tooltip | |
| 10 | + header that carries the sample sizes of the hovered day (offers · models · frontier · open · embedding). Days with a null median | |
| 11 | + are simply absent from that series — a gap, never an interpolation. | |
| 12 | +*/ | |
| 13 | +const SERIES: { key: keyof PriceIndexPointIntel; name: string; color: string }[] = [ | |
| 14 | + { key: 'median_input', name: 'Median input', color: 'var(--series-1)' }, | |
| 15 | + { key: 'median_output', name: 'Median output', color: 'var(--series-2)' }, | |
| 16 | + { key: 'median_frontier_output', name: 'Median frontier output', color: 'var(--series-3)' }, | |
| 17 | + { key: 'median_open_output', name: 'Median open-weight output', color: 'var(--series-4)' }, | |
| 18 | + { key: 'median_embedding_input', name: 'Median embedding input', color: 'var(--series-5)' }, | |
| 19 | +]; | |
| 20 | + | |
| 21 | +export function PriceIndexChart({ series, initialScale = 'linear', className }: { series: PriceIndexPointIntel[]; initialScale?: 'linear' | 'log'; className?: string }) { | |
| 22 | + const [scale, setScale] = useState<'linear' | 'log'>(initialScale); | |
| 23 | + const chart = useMemo<Series[]>( | |
| 24 | + () => | |
| 25 | + SERIES.map((s) => ({ | |
| 26 | + name: s.name, | |
| 27 | + color: s.color, | |
| 28 | + points: series.flatMap((p) => { | |
| 29 | + const v = num(p[s.key]); | |
| 30 | + return v === null ? [] : [{ x: new Date(`${p.day}T00:00:00Z`), y: v }]; | |
| 31 | + }), | |
| 32 | + })).filter((s) => s.points.length > 0), | |
| 33 | + [series], | |
| 34 | + ); | |
| 35 | + const byDay = useMemo(() => new Map(series.map((p) => [new Date(`${p.day}T00:00:00Z`).getTime(), p])), [series]); | |
| 36 | + const populatedDays = useMemo(() => series.filter((p) => SERIES.some((s) => num(p[s.key]) !== null)).length, [series]); | |
| 37 | + const set = (s: 'linear' | 'log') => { | |
| 38 | + setScale(s); | |
| 39 | + try { | |
| 40 | + const url = new URL(window.location.href); | |
| 41 | + if (s === 'log') url.searchParams.set('scale', 'log'); | |
| 42 | + else url.searchParams.delete('scale'); | |
| 43 | + window.history.replaceState(null, '', url.toString()); | |
| 44 | + } catch { | |
| 45 | + /* ignore */ | |
| 46 | + } | |
| 47 | + }; | |
| 48 | + const xFormat = (x: number) => { | |
| 49 | + const p = byDay.get(x); | |
| 50 | + const day = new Date(x).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }); | |
| 51 | + if (!p) return day; | |
| 52 | + const s = p.sample ?? {}; | |
| 53 | + const bits = [`${fmtInt(p.offers ?? s.offers)} offers`, `${fmtInt(p.models ?? s.models)} models`]; | |
| 54 | + if (num(s.frontier_offers) !== null) bits.push(`${fmtInt(s.frontier_offers)} frontier`); | |
| 55 | + if (num(s.open_models) !== null) bits.push(`${fmtInt(s.open_models)} open`); | |
| 56 | + if (num(s.embedding_models) !== null) bits.push(`${fmtInt(s.embedding_models)} embedding`); | |
| 57 | + return `${day} · ${bits.join(' · ')}`; | |
| 58 | + }; | |
| 59 | + const btn = (s: 'linear' | 'log', label: string) => ( | |
| 60 | + <button type="button" onClick={() => set(s)} aria-pressed={scale === s} className={cn('h-9 border px-3 text-xs font-medium transition-colors', scale === s ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 61 | + {label} | |
| 62 | + </button> | |
| 63 | + ); | |
| 64 | + if (populatedDays < 2) { | |
| 65 | + return ( | |
| 66 | + <p className={cn('border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3', className)} data-price-index-note> | |
| 67 | + The index has {populatedDays} day{populatedDays === 1 ? '' : 's'} of observations so far — a line needs at least two daily snapshots. The chart fills in as the daily crawl accumulates; medians below are today's. | |
| 68 | + </p> | |
| 69 | + ); | |
| 70 | + } | |
| 71 | + return ( | |
| 72 | + <div className={className} data-price-index-chart> | |
| 73 | + <div className="mb-2 flex items-center gap-1" role="group" aria-label="Y axis scale"> | |
| 74 | + {btn('linear', 'Linear')} | |
| 75 | + {btn('log', 'Log scale')} | |
| 76 | + </div> | |
| 77 | + <InteractiveLineChart series={chart} height={260} yFormat={(v) => fmtUsdPerM(v)} yLabel={`USD per 1M tokens${scale === 'log' ? ' (log scale)' : ''}`} yScale={scale} showDots xFormat={xFormat} /> | |
| 78 | + <Legend series={chart} className="mt-2" /> | |
| 79 | + </div> | |
| 80 | + ); | |
| 81 | +} | |
added
apps/web/src/components/intelligence/run-locally-form.tsx
+172 −0
@@ -0,0 +1,172 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { APPLE_PRESETS, NVIDIA_PRESETS, QUANTS } from '@/components/hardware/fit-form'; | |
| 3 | +import { BTN_GHOST, CTRL, Field } from './bits'; | |
| 4 | + | |
| 5 | +/* | |
| 6 | + GET form for /run-locally (no JavaScript needed). Memory presets by platform + a free field; GPU count; quantization; context; | |
| 7 | + batch; use-case filter. In "hardware mode" (`?hardware=<slug>`) the memory select lists the device's own memory options. | |
| 8 | +*/ | |
| 9 | +export const AMD_PRESETS = [16, 24, 32, 48, 128, 192, 256]; | |
| 10 | +export const GPU_COUNTS = [1, 2, 4, 8]; | |
| 11 | +export const PLATFORMS = [ | |
| 12 | + { value: 'any', label: 'Any platform' }, | |
| 13 | + { value: 'apple', label: 'Apple silicon (MLX, llama.cpp)' }, | |
| 14 | + { value: 'nvidia', label: 'NVIDIA (CUDA, vLLM, TensorRT-LLM)' }, | |
| 15 | + { value: 'amd', label: 'AMD (ROCm)' }, | |
| 16 | +]; | |
| 17 | +export const CONTEXT_PRESETS = [ | |
| 18 | + { value: 4096, label: '4K' }, | |
| 19 | + { value: 8192, label: '8K' }, | |
| 20 | + { value: 32768, label: '32K' }, | |
| 21 | + { value: 131072, label: '128K' }, | |
| 22 | + { value: 262144, label: '256K' }, | |
| 23 | + { value: 1000000, label: '1M' }, | |
| 24 | +]; | |
| 25 | +export const BATCHES = [1, 2, 4, 8, 16]; | |
| 26 | +export const USE_CASES = [ | |
| 27 | + { value: '', label: 'Any use case' }, | |
| 28 | + { value: 'chat', label: 'Chat' }, | |
| 29 | + { value: 'coding', label: 'Coding' }, | |
| 30 | + { value: 'reasoning', label: 'Reasoning' }, | |
| 31 | + { value: 'agentic', label: 'Agentic / tool use' }, | |
| 32 | + { value: 'vision', label: 'Vision' }, | |
| 33 | + { value: 'embeddings', label: 'Embeddings' }, | |
| 34 | + { value: 'long_context', label: 'Long context' }, | |
| 35 | +]; | |
| 36 | + | |
| 37 | +export type RunLocallyInputs = { memory: number | null; gpuCount: number; quant: string; context: number; batch: number; platform: string; useCase: string; openness: string; hardware: string | null; fitsOnly: boolean }; | |
| 38 | + | |
| 39 | +export function RunLocallyForm({ v, hardwareName, memoryOptions, className }: { v: RunLocallyInputs; hardwareName?: string | null; memoryOptions?: number[]; className?: string }) { | |
| 40 | + const presets = [...APPLE_PRESETS, ...NVIDIA_PRESETS, ...AMD_PRESETS]; | |
| 41 | + const presetSelected = v.memory !== null && (memoryOptions?.includes(v.memory) || presets.includes(v.memory)) ? String(v.memory) : ''; | |
| 42 | + return ( | |
| 43 | + <form action="/run-locally" method="get" className={`space-y-3 ${className ?? ''}`} data-run-locally-form> | |
| 44 | + {v.hardware && ( | |
| 45 | + <div className="border border-rule bg-surface-2 px-2.5 py-2 text-xs text-ink-2"> | |
| 46 | + <input type="hidden" name="hardware" value={v.hardware} /> | |
| 47 | + Device: <span className="font-medium text-ink">{hardwareName ?? v.hardware}</span>{' '} | |
| 48 | + <Link href="/run-locally" className="link"> | |
| 49 | + change | |
| 50 | + </Link> | |
| 51 | + </div> | |
| 52 | + )} | |
| 53 | + {!v.hardware && ( | |
| 54 | + <Field label="Platform"> | |
| 55 | + <select name="platform" defaultValue={v.platform} className={CTRL}> | |
| 56 | + {PLATFORMS.map((p) => ( | |
| 57 | + <option key={p.value} value={p.value}> | |
| 58 | + {p.label} | |
| 59 | + </option> | |
| 60 | + ))} | |
| 61 | + </select> | |
| 62 | + </Field> | |
| 63 | + )} | |
| 64 | + <Field label={v.hardware ? 'Device memory' : 'Memory per device (preset)'}> | |
| 65 | + <select name="memory_gb" defaultValue={presetSelected} className={CTRL}> | |
| 66 | + <option value="">{v.hardware ? 'Largest configuration' : 'Choose a preset…'}</option> | |
| 67 | + {memoryOptions && memoryOptions.length > 0 ? ( | |
| 68 | + memoryOptions.map((g) => ( | |
| 69 | + <option key={g} value={g}> | |
| 70 | + {g} GB | |
| 71 | + </option> | |
| 72 | + )) | |
| 73 | + ) : ( | |
| 74 | + <> | |
| 75 | + <optgroup label="Apple silicon (unified memory)"> | |
| 76 | + {APPLE_PRESETS.map((g) => ( | |
| 77 | + <option key={`a${g}`} value={g}> | |
| 78 | + {g} GB | |
| 79 | + </option> | |
| 80 | + ))} | |
| 81 | + </optgroup> | |
| 82 | + <optgroup label="NVIDIA (VRAM)"> | |
| 83 | + {NVIDIA_PRESETS.map((g) => ( | |
| 84 | + <option key={`n${g}`} value={g}> | |
| 85 | + {g} GB | |
| 86 | + </option> | |
| 87 | + ))} | |
| 88 | + </optgroup> | |
| 89 | + <optgroup label="AMD (VRAM)"> | |
| 90 | + {AMD_PRESETS.map((g) => ( | |
| 91 | + <option key={`d${g}`} value={g}> | |
| 92 | + {g} GB | |
| 93 | + </option> | |
| 94 | + ))} | |
| 95 | + </optgroup> | |
| 96 | + </> | |
| 97 | + )} | |
| 98 | + </select> | |
| 99 | + </Field> | |
| 100 | + {!v.hardware && ( | |
| 101 | + <Field label="…or any memory (GB)" hint="Wins over the preset when filled."> | |
| 102 | + <input name="memory_custom" inputMode="decimal" pattern="[0-9]*\.?[0-9]*" defaultValue={presetSelected ? '' : v.memory ?? ''} placeholder="e.g. 20" className={CTRL} /> | |
| 103 | + </Field> | |
| 104 | + )} | |
| 105 | + <Field label="GPUs / devices" hint="Memories are summed; interconnect is not modelled."> | |
| 106 | + <select name="gpu_count" defaultValue={String(v.gpuCount)} className={CTRL}> | |
| 107 | + {GPU_COUNTS.map((n) => ( | |
| 108 | + <option key={n} value={n}> | |
| 109 | + {n} | |
| 110 | + </option> | |
| 111 | + ))} | |
| 112 | + </select> | |
| 113 | + </Field> | |
| 114 | + <Field label="Quantization"> | |
| 115 | + <select name="quant" defaultValue={v.quant} className={CTRL}> | |
| 116 | + {QUANTS.map((q) => ( | |
| 117 | + <option key={q.value} value={q.value}> | |
| 118 | + {q.label} | |
| 119 | + </option> | |
| 120 | + ))} | |
| 121 | + </select> | |
| 122 | + </Field> | |
| 123 | + <Field label="Context window"> | |
| 124 | + <select name="context" defaultValue={String(v.context)} className={CTRL}> | |
| 125 | + {CONTEXT_PRESETS.map((c) => ( | |
| 126 | + <option key={c.value} value={c.value}> | |
| 127 | + {c.label} tokens | |
| 128 | + </option> | |
| 129 | + ))} | |
| 130 | + {!CONTEXT_PRESETS.some((c) => c.value === v.context) && <option value={v.context}>{v.context} tokens</option>} | |
| 131 | + </select> | |
| 132 | + </Field> | |
| 133 | + <Field label="Batch (concurrent sequences)"> | |
| 134 | + <select name="batch" defaultValue={String(v.batch)} className={CTRL}> | |
| 135 | + {BATCHES.map((b) => ( | |
| 136 | + <option key={b} value={b}> | |
| 137 | + {b} | |
| 138 | + </option> | |
| 139 | + ))} | |
| 140 | + </select> | |
| 141 | + </Field> | |
| 142 | + <Field label="Use case"> | |
| 143 | + <select name="use_case" defaultValue={v.useCase} className={CTRL}> | |
| 144 | + {USE_CASES.map((u) => ( | |
| 145 | + <option key={u.value} value={u.value}> | |
| 146 | + {u.label} | |
| 147 | + </option> | |
| 148 | + ))} | |
| 149 | + </select> | |
| 150 | + </Field> | |
| 151 | + <Field label="Openness"> | |
| 152 | + <select name="openness" defaultValue={v.openness} className={CTRL}> | |
| 153 | + <option value="">Any downloadable weights</option> | |
| 154 | + <option value="open-source">Open source</option> | |
| 155 | + <option value="open-weights">Open weights</option> | |
| 156 | + <option value="restricted-weights">Restricted weights</option> | |
| 157 | + </select> | |
| 158 | + </Field> | |
| 159 | + <label className="flex min-h-11 items-center gap-2 text-sm text-ink-2"> | |
| 160 | + <input type="checkbox" name="fits" value="1" defaultChecked={v.fitsOnly} className="size-4 accent-[var(--accent)]" /> Only models that fit | |
| 161 | + </label> | |
| 162 | + <div className="flex gap-2"> | |
| 163 | + <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink lg:h-10 px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 164 | + Estimate | |
| 165 | + </button> | |
| 166 | + <Link href="/run-locally" className={BTN_GHOST}> | |
| 167 | + Reset | |
| 168 | + </Link> | |
| 169 | + </div> | |
| 170 | + </form> | |
| 171 | + ); | |
| 172 | +} | |
modified
apps/web/src/components/prices/movers.tsx
+42 −6
@@ -1,7 +1,9 @@ | ||
| 1 | 1 | import { ExternalLink } from 'lucide-react'; |
| 2 | +import Link from 'next/link'; | |
| 2 | 3 | import { EntityLink } from '@/components/ui/entity'; |
| 3 | 4 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 4 | 5 | import { Note } from '@/components/ui/section'; |
| 6 | +import { cn } from '@/lib/cn'; | |
| 5 | 7 | import { fmtDateTime, fmtAgo, fmtUsdPerM, num } from '@/lib/format'; |
| 6 | 8 | import type { ChangeEvent } from '@/lib/types'; |
| 7 | 9 | |
@@ -20,6 +22,12 @@ function host(url: string): string { | ||
| 20 | 22 | } |
| 21 | 23 | } |
| 22 | 24 | |
| 25 | +/** Relative change in %, null when either side is missing or the base is 0. */ | |
| 26 | +export function pctChange(from: number | null, to: number | null): number | null { | |
| 27 | + if (from === null || to === null || from === 0) return null; | |
| 28 | + return ((to - from) / Math.abs(from)) * 100; | |
| 29 | +} | |
| 30 | + | |
| 23 | 31 | /** Old → new price cell: strike-through old, bold new, direction sign. Amber = money. */ |
| 24 | 32 | function Move({ from, to }: { from: number | null; to: number | null }) { |
| 25 | 33 | if (from === null && to === null) return <span className="text-ink-3">—</span>; |
@@ -34,11 +42,22 @@ function Move({ from, to }: { from: number | null; to: number | null }) { | ||
| 34 | 42 | ); |
| 35 | 43 | } |
| 36 | 44 | |
| 37 | −/** PRICE_CHANGED events as a dense table: model · provider · input move · output move · when · source. */ | |
| 38 | −export function PriceMovers({ movers }: { movers: ChangeEvent[] }) { | |
| 39 | − const rows = movers.filter((e) => e.event_type === 'PRICE_CHANGED' || e.category === 'price'); | |
| 45 | +/** ±% chip; green when cheaper, red when dearer. */ | |
| 46 | +export function PctChip({ pct, className }: { pct: number | null; className?: string }) { | |
| 47 | + if (pct === null) return <span className={cn('text-ink-3', className)}>—</span>; | |
| 48 | + const sign = pct > 0 ? '+' : pct < 0 ? '−' : ''; | |
| 49 | + return <span className={cn('tnum font-medium', pct < 0 ? 'text-positive' : pct > 0 ? 'text-danger' : 'text-ink-2', className)}>{`${sign}${Math.abs(pct).toFixed(Math.abs(pct) < 10 ? 1 : 0)}%`}</span>; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** | |
| 53 | + * PRICE_CHANGED events as a dense table: model · provider · input move · output move · % change · when · source. | |
| 54 | + * `providerHref(slug)` optionally turns the provider cell into a filter link (the price terminal). | |
| 55 | + */ | |
| 56 | +export function PriceMovers({ movers, providerHref, limit }: { movers: (ChangeEvent & { percent_change?: unknown })[]; providerHref?: (providerName: string) => string | undefined; limit?: number }) { | |
| 57 | + const rows = movers.filter((e) => e.event_type === 'PRICE_CHANGED' || e.category === 'price').slice(0, limit ?? movers.length); | |
| 40 | 58 | return ( |
| 41 | 59 | <> |
| 60 | + <div className="md:overflow-x-auto"> | |
| 42 | 61 | <DataTable caption="Recent price changes"> |
| 43 | 62 | <thead> |
| 44 | 63 | <tr> |
@@ -46,25 +65,41 @@ export function PriceMovers({ movers }: { movers: ChangeEvent[] }) { | ||
| 46 | 65 | <Th>Provider</Th> |
| 47 | 66 | <Th>Input / 1M</Th> |
| 48 | 67 | <Th>Output / 1M</Th> |
| 68 | + <Th num>Change</Th> | |
| 49 | 69 | <Th>Observed</Th> |
| 50 | 70 | <Th>Source</Th> |
| 51 | 71 | </tr> |
| 52 | 72 | </thead> |
| 53 | 73 | <tbody> |
| 54 | − {rows.length === 0 && <EmptyRow cols={6}>No price change recorded in this window. The first observation of a price is not a change.</EmptyRow>} | |
| 74 | + {rows.length === 0 && <EmptyRow cols={7}>No price change recorded in this window. The first observation of a price is not a change.</EmptyRow>} | |
| 55 | 75 | {rows.map((e) => { |
| 56 | 76 | const o = pv(e.old_value); |
| 57 | 77 | const n = pv(e.new_value); |
| 58 | 78 | const provider = typeof e.meta?.provider === 'string' ? e.meta.provider : null; |
| 79 | + const apiPct = num(e.percent_change); | |
| 80 | + const pIn = pctChange(o.input, n.input); | |
| 81 | + const pOut = pctChange(o.output, n.output); | |
| 82 | + const href = provider && providerHref ? providerHref(provider) : undefined; | |
| 59 | 83 | return ( |
| 60 | 84 | <tr key={e.id}> |
| 61 | 85 | <Td primary> |
| 62 | 86 | {e.entity ? <EntityLink e={e.entity} /> : <span className="text-ink-3">—</span>} |
| 63 | 87 | {e.entity?.organization && <span className="ml-2 text-xs text-ink-3">{e.entity.organization.name}</span>} |
| 64 | 88 | </Td> |
| 65 | − <Td label="Provider" className="text-ink-2">{provider ?? <span className="text-ink-3">—</span>}</Td> | |
| 89 | + <Td label="Provider" className="text-ink-2">{provider ? href ? <Link href={href} className="hover:text-accent" title="Filter by this provider">{provider}</Link> : provider : <span className="text-ink-3">—</span>}</Td> | |
| 66 | 90 | <Td label="Input / 1M"><Move from={o.input} to={n.input} /></Td> |
| 67 | 91 | <Td label="Output / 1M"><Move from={o.output} to={n.output} /></Td> |
| 92 | + <Td num label="Change" className="tnum text-xs"> | |
| 93 | + {apiPct !== null ? ( | |
| 94 | + <PctChip pct={apiPct} /> | |
| 95 | + ) : ( | |
| 96 | + <span className="inline-flex flex-col items-end gap-0.5 md:flex-row md:items-center md:gap-2"> | |
| 97 | + {pIn !== null && <span>in <PctChip pct={pIn} /></span>} | |
| 98 | + {pOut !== null && <span>out <PctChip pct={pOut} /></span>} | |
| 99 | + {pIn === null && pOut === null && <span className="text-ink-3">—</span>} | |
| 100 | + </span> | |
| 101 | + )} | |
| 102 | + </Td> | |
| 68 | 103 | <Td label="Observed" className="text-ink-2" title={fmtDateTime(e.observed_at)}>{fmtAgo(e.observed_at)}</Td> |
| 69 | 104 | <Td label="Source"> |
| 70 | 105 | {e.source_url ? ( |
@@ -80,7 +115,8 @@ export function PriceMovers({ movers }: { movers: ChangeEvent[] }) { | ||
| 80 | 115 | })} |
| 81 | 116 | </tbody> |
| 82 | 117 | </DataTable> |
| 83 | − {rows.length > 0 && <Note className="mt-3">Movers are PRICE_CHANGED events emitted when a provider's published price for a model differs from the previous observation. Green arrow = cheaper, red = dearer.</Note>} | |
| 118 | + </div> | |
| 119 | + {rows.length > 0 && <Note className="mt-3">Movers are PRICE_CHANGED events emitted when a provider's published price for a model differs from the previous observation. Green = cheaper, red = dearer; % is relative to the previous published price.</Note>} | |
| 84 | 120 | </> |
| 85 | 121 | ); |
| 86 | 122 | } |
added
apps/web/src/components/providers/provider-strip.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { RangeBar, distDomain } from '@/components/intelligence/bits'; | |
| 2 | +import { DataStrip, type StripItem } from '@/components/layout/terminal'; | |
| 3 | +import { fmtInt, fmtSigned, num } from '@/lib/format'; | |
| 4 | +import type { ProviderIntelRow } from '@/lib/types'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Provider aggregates as a `DataStrip` (provider page header): models served · organizations covered · input / output price | |
| 8 | + * distributions (range bars) · price changes 30 d · models added / removed 30 d. `domain` lets the caller share the log axis | |
| 9 | + * with a table of every provider; alone, the provider's own extent is used. | |
| 10 | + */ | |
| 11 | +export function ProviderStrip({ p, note, domainIn, domainOut }: { p: ProviderIntelRow; note?: string | null; domainIn?: [number, number]; domainOut?: [number, number] }) { | |
| 12 | + const din = domainIn ?? distDomain([p.input_price_distribution]); | |
| 13 | + const dout = domainOut ?? distDomain([p.output_price_distribution]); | |
| 14 | + const items: StripItem[] = [ | |
| 15 | + { label: 'Models served', value: fmtInt(p.model_count), definition: 'Canonical models with at least one current offer at this provider.' }, | |
| 16 | + { label: 'Organizations covered', value: fmtInt(p.organizations_covered), definition: 'Distinct organizations behind the models this provider currently lists.' }, | |
| 17 | + { label: 'Input price', value: <RangeBar d={p.input_price_distribution} domain={din} label="Input price distribution" />, definition: note ?? 'min · p25 · median · p75 · max of live input prices (USD / 1M tokens) over current offers with a positive price.' }, | |
| 18 | + { label: 'Output price', value: <RangeBar d={p.output_price_distribution} domain={dout} label="Output price distribution" />, definition: 'min · p25 · median · p75 · max of live output prices over current offers with a positive price.' }, | |
| 19 | + { label: 'Price changes · 30 d', value: fmtInt(p.price_changes_30d), definition: 'PRICE_CHANGED events (not back-filled) in the last 30 days on models this provider currently lists.' }, | |
| 20 | + { label: 'Added / removed · 30 d', value: <span className="tnum">{fmtSigned(p.models_added_30d)} <span className="text-ink-3">/</span> {num(p.models_removed_30d) ? `−${fmtInt(p.models_removed_30d)}` : '0'}</span>, definition: 'Model listings first opened / last closed at this provider in the last 30 days.' }, | |
| 21 | + ]; | |
| 22 | + return <DataStrip items={items} dense />; | |
| 23 | +} | |
modified
apps/web/src/lib/client-api.ts
+39 −0
@@ -33,3 +33,42 @@ export const clientApi = { | ||
| 33 | 33 | view: (path: string) => |
| 34 | 34 | fetch('/api/v1/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }), keepalive: true }).catch(() => undefined), |
| 35 | 35 | }; |
| 36 | + | |
| 37 | +// ---- D2 (intelligence) ---- | |
| 38 | +import type { CostContextPayload, CostPayload, Price } from './types'; | |
| 39 | +/** Browser-side calls used by the price terminal (lazy row sparklines), the calculator and provider pages. */ | |
| 40 | +export const clientIntel = { | |
| 41 | + /** Full price history of one model (optionally one provider). */ | |
| 42 | + priceHistory: (model: string, provider?: string, signal?: AbortSignal) => get<{ items: Price[] }>(`/prices/history?model=${enc(model)}${provider ? `&provider=${enc(provider)}` : ''}`, signal), | |
| 43 | + /** `/cost` — query string already built by the caller (`model=&input_tokens=…`). */ | |
| 44 | + cost: (qs: string, signal?: AbortSignal) => get<CostPayload>(`/cost?${qs}`, signal), | |
| 45 | + /** `/cost/context?tokens=`. */ | |
| 46 | + costContext: (qs: string, signal?: AbortSignal) => get<CostContextPayload>(`/cost/context?${qs}`, signal), | |
| 47 | +}; | |
| 48 | +// ---- /D2 ---- | |
| 49 | + | |
| 50 | +// ---- D3 (temporal/graph/admin) ---- | |
| 51 | +import type { GraphExploreMode, GraphExplorePayload } from './types'; | |
| 52 | +/** Graph explorer: root change and progressive neighbourhood expansion (merged client-side). */ | |
| 53 | +export function clientGraphExplore(node: string, mode: GraphExploreMode, depth: 1 | 2 = 1, limit = 150, signal?: AbortSignal): Promise<GraphExplorePayload> { | |
| 54 | + return get<GraphExplorePayload>(`/graph/explore?node=${enc(node)}&mode=${enc(mode)}&depth=${depth}&limit=${limit}`, signal); | |
| 55 | +} | |
| 56 | +/** Same-origin GET of any public route for the /developers request builder (returns status + parsed body or text). */ | |
| 57 | +export async function clientTry(path: string, signal?: AbortSignal): Promise<{ status: number; ms: number; body: unknown; headers: Record<string, string> }> { | |
| 58 | + const t0 = performance.now(); | |
| 59 | + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal }); | |
| 60 | + const text = await res.text(); | |
| 61 | + let body: unknown = text; | |
| 62 | + try { | |
| 63 | + body = JSON.parse(text); | |
| 64 | + } catch { | |
| 65 | + /* keep text */ | |
| 66 | + } | |
| 67 | + const headers: Record<string, string> = {}; | |
| 68 | + for (const k of ['x-api-version', 'etag', 'cache-control', 'content-type']) { | |
| 69 | + const v = res.headers.get(k); | |
| 70 | + if (v) headers[k] = v; | |
| 71 | + } | |
| 72 | + return { status: res.status, ms: Math.round(performance.now() - t0), body, headers }; | |
| 73 | +} | |
| 74 | +// ---- /D3 ---- | |
| 36 | 75 | |