chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@sim/emcn'
|
||||
|
||||
export default function ModelDetailLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader animate className='size-6 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
getModelBySlug,
|
||||
getProviderBySlug,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
import { createLandingOgImage } from '@/app/(landing)/og-utils'
|
||||
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string; model: string }>
|
||||
}) {
|
||||
const { provider: providerSlug, model: modelSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
const model = getModelBySlug(providerSlug, modelSlug)
|
||||
|
||||
if (!provider || !model) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return createLandingOgImage({
|
||||
eyebrow: `${provider.name} model`,
|
||||
title: model.displayName,
|
||||
subtitle: `${provider.name} pricing, context window, and feature support generated from Sim's model registry.`,
|
||||
pills: [
|
||||
`Input ${formatPrice(model.pricing.input)}/1M`,
|
||||
`Output ${formatPrice(model.pricing.output)}/1M`,
|
||||
model.contextWindow ? `${formatTokenCount(model.contextWindow)} context` : 'Unknown context',
|
||||
model.capabilityTags[0] ?? 'Capabilities tracked',
|
||||
],
|
||||
domainLabel: `sim.ai${model.href}`,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { BackLink } from '@/app/(landing)/components'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import { ShareButton } from '@/app/(landing)/components/share-button'
|
||||
import { FeaturedModelCard, ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
|
||||
import {
|
||||
ALL_CATALOG_MODELS,
|
||||
buildModelCapabilityFacts,
|
||||
buildModelFaqs,
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
formatUpdatedAt,
|
||||
getEffectiveMaxOutputTokens,
|
||||
getModelBySlug,
|
||||
getPricingBounds,
|
||||
getProviderBySlug,
|
||||
getRelatedModels,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
export const dynamicParams = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return ALL_CATALOG_MODELS.map((model) => ({
|
||||
provider: model.providerSlug,
|
||||
model: model.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string; model: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { provider: providerSlug, model: modelSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
const model = getModelBySlug(providerSlug, modelSlug)
|
||||
|
||||
if (!provider || !model) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${model.displayName} Pricing, Context Window, and Features`,
|
||||
description: `${model.displayName} by ${provider.name}: pricing, cached input cost, output cost, context window, and capability support. Explore the full generated model page on Sim.`,
|
||||
keywords: [
|
||||
model.displayName,
|
||||
`${model.displayName} pricing`,
|
||||
`${model.displayName} context window`,
|
||||
`${model.displayName} features`,
|
||||
`${provider.name} ${model.displayName}`,
|
||||
`${provider.name} model pricing`,
|
||||
...model.capabilityTags,
|
||||
],
|
||||
// og:image/twitter:image come from the sibling opengraph-image.tsx -
|
||||
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
|
||||
openGraph: {
|
||||
title: `${model.displayName} Pricing, Context Window, and Features | Sim`,
|
||||
description: `${model.displayName} by ${provider.name}: pricing, context window, and model capability details.`,
|
||||
url: `${baseUrl}${model.href}`,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${model.displayName} | Sim`,
|
||||
description: model.summary,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}${model.href}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ModelPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string; model: string }>
|
||||
}) {
|
||||
const { provider: providerSlug, model: modelSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
const model = getModelBySlug(providerSlug, modelSlug)
|
||||
|
||||
if (!provider || !model) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const faqs = buildModelFaqs(provider, model)
|
||||
const capabilityFacts = buildModelCapabilityFacts(model)
|
||||
const pricingBounds = getPricingBounds(model.pricing)
|
||||
const relatedModels = getRelatedModels(model, 6)
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
|
||||
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 4,
|
||||
name: model.displayName,
|
||||
item: `${baseUrl}${model.href}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const productJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: model.displayName,
|
||||
brand: provider.name,
|
||||
category: 'AI language model',
|
||||
description: model.summary,
|
||||
sku: model.id,
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
priceCurrency: 'USD',
|
||||
lowPrice: pricingBounds.lowPrice.toString(),
|
||||
highPrice: pricingBounds.highPrice.toString(),
|
||||
},
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<JsonLd data={productJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<section className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
<div className='mb-6'>
|
||||
<BackLink href={provider.href} label={`Back to ${provider.name}`} />
|
||||
</div>
|
||||
|
||||
<div className='mb-6 flex items-center gap-5'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-16 rounded-xl'
|
||||
iconClassName='size-8'
|
||||
/>
|
||||
<div>
|
||||
<p className='mb-0.5 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
{provider.name} model
|
||||
</p>
|
||||
<h1
|
||||
id='model-heading'
|
||||
className='text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] sm:text-[36px] lg:text-[44px]'
|
||||
>
|
||||
{model.displayName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='mb-8 max-w-[700px] text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
{model.summary}
|
||||
{model.bestFor ? ` ${model.bestFor}` : ''}
|
||||
</p>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<ChipLink variant='primary' href='/'>
|
||||
Build with this model
|
||||
</ChipLink>
|
||||
<ChipLink href={provider.href} className='border border-[var(--border-1)]'>
|
||||
All {provider.name} models
|
||||
</ChipLink>
|
||||
<ShareButton url={`${baseUrl}${model.href}`} title={model.displayName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1460px]'>
|
||||
<div className='mx-20 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
|
||||
<InfoRow label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
|
||||
<InfoRow
|
||||
label='Cached input'
|
||||
value={
|
||||
model.pricing.cachedInput !== undefined
|
||||
? `${formatPrice(model.pricing.cachedInput)}/1M`
|
||||
: 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoRow label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
|
||||
<InfoRow
|
||||
label='Context window'
|
||||
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
|
||||
/>
|
||||
<InfoRow
|
||||
label='Max output'
|
||||
value={
|
||||
model.capabilities.maxOutputTokens
|
||||
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
|
||||
: 'Not published'
|
||||
}
|
||||
/>
|
||||
<InfoRow label='Provider' value={provider.name} />
|
||||
<InfoRow label='Updated' value={formatUpdatedAt(model.pricing.updatedAt)} />
|
||||
{model.bestFor ? <InfoRow label='Best for' value={model.bestFor} /> : null}
|
||||
|
||||
{capabilityFacts.length > 0 && (
|
||||
<>
|
||||
{capabilityFacts.map((item) => (
|
||||
<InfoRow key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{relatedModels.length > 0 && (
|
||||
<>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
<nav aria-label='Related models' className='flex flex-col sm:flex-row'>
|
||||
{relatedModels.slice(0, 3).map((entry) => (
|
||||
<FeaturedModelCard key={entry.id} provider={provider} model={entry} />
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='model-faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='model-faq-heading'
|
||||
className='mb-8 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqs} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className='flex items-baseline justify-between gap-4 border-[var(--border)] border-t px-6 py-4 first:border-t-0'>
|
||||
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>{label}</span>
|
||||
<span className='text-right text-[14px] text-[var(--text-primary)] leading-snug'>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Loader } from '@sim/emcn'
|
||||
|
||||
export default function ModelProviderLoading() {
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader animate className='size-6 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
getCheapestProviderModel,
|
||||
getLargestContextProviderModel,
|
||||
getProviderBySlug,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
import { createLandingOgImage } from '@/app/(landing)/og-utils'
|
||||
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ provider: string }> }) {
|
||||
const { provider: providerSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
|
||||
if (!provider || provider.models.length === 0) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const cheapestModel = getCheapestProviderModel(provider)
|
||||
const largestContextModel = getLargestContextProviderModel(provider)
|
||||
|
||||
return createLandingOgImage({
|
||||
eyebrow: `${provider.name} on Sim`,
|
||||
title: `${provider.name} models`,
|
||||
subtitle: `Browse ${provider.modelCount} tracked ${provider.name} models with pricing, context windows, default model selection, and model capability coverage.`,
|
||||
pills: [
|
||||
`${provider.modelCount} tracked`,
|
||||
provider.defaultModelDisplayName || 'Dynamic default',
|
||||
cheapestModel ? `From ${formatPrice(cheapestModel.pricing.input)}/1M` : 'Pricing tracked',
|
||||
largestContextModel?.contextWindow
|
||||
? `${formatTokenCount(largestContextModel.contextWindow)} context`
|
||||
: 'Context tracked',
|
||||
],
|
||||
domainLabel: `sim.ai${provider.href}`,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { BackLink, ChevronArrow } from '@/app/(landing)/components'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import {
|
||||
FeaturedModelCard,
|
||||
FeaturedProviderCard,
|
||||
ProviderIcon,
|
||||
} from '@/app/(landing)/models/components/model-primitives'
|
||||
import { ModelTimelineChart } from '@/app/(landing)/models/components/model-timeline-chart'
|
||||
import {
|
||||
buildProviderFaqs,
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
getProviderBySlug,
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
TOP_MODEL_PROVIDERS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
export const dynamicParams = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
provider: provider.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { provider: providerSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
|
||||
if (!provider || provider.models.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const providerFaqs = buildProviderFaqs(provider)
|
||||
|
||||
return {
|
||||
title: `${provider.name} Models`,
|
||||
description: `Browse ${provider.modelCount} ${provider.name} models tracked in Sim. Compare pricing, context windows, default model selection, and capabilities for ${provider.name}'s AI model lineup.`,
|
||||
keywords: [
|
||||
`${provider.name} models`,
|
||||
`${provider.name} pricing`,
|
||||
`${provider.name} context window`,
|
||||
`${provider.name} model list`,
|
||||
`${provider.name} AI models`,
|
||||
...provider.models.slice(0, 6).map((model) => model.displayName),
|
||||
],
|
||||
// og:image/twitter:image come from the sibling opengraph-image.tsx -
|
||||
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
|
||||
openGraph: {
|
||||
title: `${provider.name} Models | Sim`,
|
||||
description: `Explore ${provider.modelCount} ${provider.name} models with pricing and capability details.`,
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${provider.name} Models | Sim`,
|
||||
description: providerFaqs[0]?.answer ?? provider.summary,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}${provider.href}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProviderModelsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}) {
|
||||
const { provider: providerSlug } = await params
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
|
||||
if (!provider || provider.models.length === 0) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const faqs = buildProviderFaqs(provider)
|
||||
const relatedProviders = MODEL_PROVIDERS_WITH_CATALOGS.filter(
|
||||
(entry) => entry.id !== provider.id && TOP_MODEL_PROVIDERS.includes(entry.name)
|
||||
).slice(0, 4)
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
|
||||
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `${provider.name} Models`,
|
||||
description: `List of ${provider.modelCount} ${provider.name} models tracked in Sim.`,
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
numberOfItems: provider.modelCount,
|
||||
itemListElement: provider.models.map((model, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `${baseUrl}${model.href}`,
|
||||
name: model.displayName,
|
||||
})),
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<JsonLd data={itemListJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<section className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
<div className='mb-6'>
|
||||
<BackLink href='/models' label='Back to Models' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-12 rounded-xl'
|
||||
iconClassName='size-6'
|
||||
/>
|
||||
<h1
|
||||
id='provider-heading'
|
||||
className='text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
{provider.name} models
|
||||
</h1>
|
||||
</div>
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
{provider.modelCount} models
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1460px]'>
|
||||
<div className='mx-20 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
|
||||
{provider.featuredModels.length > 0 && (
|
||||
<>
|
||||
<nav aria-label='Featured models' className='flex flex-col sm:flex-row'>
|
||||
{provider.featuredModels.slice(0, 3).map((model) => (
|
||||
<FeaturedModelCard key={model.id} provider={provider} model={model} />
|
||||
))}
|
||||
</nav>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ModelTimelineChart models={provider.models} providerId={provider.id} />
|
||||
|
||||
{provider.models.map((model) => (
|
||||
<Link
|
||||
key={model.id}
|
||||
href={model.href}
|
||||
className='group/link flex items-center gap-4 border-[var(--border)] border-t px-6 py-4 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<h3 className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
|
||||
{model.displayName}
|
||||
</h3>
|
||||
<p className='line-clamp-1 hidden text-[12px] text-[var(--text-muted)] leading-[150%] sm:block'>
|
||||
{model.id}
|
||||
</p>
|
||||
</div>
|
||||
<span className='hidden shrink-0 text-[11px] text-[var(--text-muted)] uppercase tracking-[0.1em] md:block'>
|
||||
{formatPrice(model.pricing.input)}/1M in
|
||||
</span>
|
||||
<span className='hidden shrink-0 text-[11px] text-[var(--text-muted)] uppercase tracking-[0.1em] md:block'>
|
||||
{formatPrice(model.pricing.output)}/1M out
|
||||
</span>
|
||||
{model.contextWindow ? (
|
||||
<span className='hidden shrink-0 text-[11px] text-[var(--text-muted)] uppercase tracking-[0.1em] lg:block'>
|
||||
{formatTokenCount(model.contextWindow)} ctx
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{relatedProviders.length > 0 && (
|
||||
<>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
<nav aria-label='Related providers' className='flex flex-col sm:flex-row'>
|
||||
{relatedProviders.map((entry) => (
|
||||
<FeaturedProviderCard key={entry.id} provider={entry} />
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='provider-faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='provider-faq-heading'
|
||||
className='mb-8 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqs} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Models route segment. The shared landing layout owns the chrome (navbar,
|
||||
* footer, site-wide JSON-LD, scroll port); this layout only provides the
|
||||
* `<main>` landmark. Pages emit their own page-specific JSON-LD.
|
||||
*/
|
||||
export default function ModelsLayout({ children }: { children: ReactNode }) {
|
||||
return <main id='main-content'>{children}</main>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
formatTokenCount,
|
||||
MAX_CONTEXT_WINDOW,
|
||||
TOTAL_MODEL_PROVIDERS,
|
||||
TOTAL_MODELS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
import { createLandingOgImage } from '@/app/(landing)/og-utils'
|
||||
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export default async function Image() {
|
||||
return createLandingOgImage({
|
||||
eyebrow: 'Sim model directory',
|
||||
title: 'AI Models Directory',
|
||||
subtitle:
|
||||
'Browse tracked AI models with pricing, context windows, and workflow-ready capability details.',
|
||||
pills: [
|
||||
`${TOTAL_MODELS} models`,
|
||||
`${TOTAL_MODEL_PROVIDERS} providers`,
|
||||
`${formatTokenCount(MAX_CONTEXT_WINDOW)} max context`,
|
||||
],
|
||||
domainLabel: 'sim.ai/models',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { withFilteredNoindex } from '@/lib/landing/seo'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import { ModelComparisonCharts } from '@/app/(landing)/models/components/model-comparison-charts'
|
||||
import { ModelDirectory } from '@/app/(landing)/models/components/model-directory'
|
||||
import {
|
||||
FeaturedModelCard,
|
||||
FeaturedProviderCard,
|
||||
} from '@/app/(landing)/models/components/model-primitives'
|
||||
import { modelsSearchParamsCache } from '@/app/(landing)/models/search-params'
|
||||
import {
|
||||
ALL_CATALOG_MODELS,
|
||||
getPricingBounds,
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
TOP_MODEL_PROVIDERS,
|
||||
TOTAL_MODEL_PROVIDERS,
|
||||
TOTAL_MODELS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
const FEATURED_PROVIDER_ORDER = ['anthropic', 'openai', 'google']
|
||||
|
||||
const MODELS_BREADCRUMB_JSON_LD = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
|
||||
],
|
||||
}
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'Which AI models are best for building agents and automated workflows?',
|
||||
answer:
|
||||
'The most important factors for agent tasks are reliable tool use (function calling), a large enough context window to track conversation history and tool outputs, and consistent instruction following. In Sim, OpenAI GPT-4.1, Anthropic Claude Sonnet, and Google Gemini 2.5 Pro are popular choices, and each supports tool use, structured outputs, and context windows of 128K tokens or more. For cost-sensitive or high-throughput agents, Groq and Cerebras offer significantly faster inference at lower cost.',
|
||||
},
|
||||
{
|
||||
question: 'What does context window size mean when running an AI agent?',
|
||||
answer:
|
||||
'The context window is the total number of tokens a model can process in a single call, including your system prompt, conversation history, tool call results, and any documents you pass in. For agents running multi-step tasks, context fills up quickly, and each tool result and each retrieved document adds tokens. A 128K-token context window fits roughly 300 pages of text; models like Gemini 2.5 Pro support up to 1M tokens, enough to hold an entire codebase in a single pass.',
|
||||
},
|
||||
{
|
||||
question: 'Are model prices shown per million tokens?',
|
||||
answer:
|
||||
'Yes. Input, cached input, and output prices are all listed per one million tokens, matching how providers bill through their APIs. For agents that chain multiple calls, costs compound quickly: an agent completing 100 turns at 10K tokens each consumes roughly 1M tokens per session. Cached input pricing applies when a provider supports prompt caching, where a repeated prefix like a system prompt is billed at a reduced rate.',
|
||||
},
|
||||
{
|
||||
question: 'Which AI models support tool use and function calling?',
|
||||
answer:
|
||||
'Tool use, also called function calling, lets an agent invoke external APIs, query databases, run code, or take any action you define. In Sim, all first-party models from OpenAI, Anthropic, Google, Mistral, Groq, Cerebras, and xAI support tool use. Look for the Tool Use capability tag on any model card in this directory to confirm support.',
|
||||
},
|
||||
{
|
||||
question: 'How do I add a model to a Sim agent?',
|
||||
answer:
|
||||
'Open Sim, add an Agent block, and select your provider and model from the model picker inside that block. Every model listed in this directory is available in the Agent block. Swapping models takes one click and does not affect the rest of your agent, making it straightforward to test different models on the same task without rebuilding anything.',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* `q`/`provider` render a genuinely different server-rendered list (see
|
||||
* search-params.ts), so filtered URLs are noindexed rather than
|
||||
* self-canonicalized — keeps the single indexable URL as the bare directory
|
||||
* page instead of asking Google to index every filter permutation.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<Metadata> {
|
||||
const { q, provider } = await modelsSearchParamsCache.parse(searchParams)
|
||||
const isFiltered = Boolean(q || provider)
|
||||
|
||||
return withFilteredNoindex(
|
||||
{
|
||||
title: 'AI Models Directory',
|
||||
description: `Compare ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers in Sim's AI workspace. Compare pricing, context windows, and capabilities for your agents.`,
|
||||
keywords: [
|
||||
'AI models directory',
|
||||
'AI model comparison',
|
||||
'LLM model list',
|
||||
'model pricing',
|
||||
'context window comparison',
|
||||
'OpenAI models',
|
||||
'Anthropic models',
|
||||
'Google Gemini models',
|
||||
'xAI Grok models',
|
||||
'Mistral models',
|
||||
...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`),
|
||||
],
|
||||
// og:image/twitter:image come from the sibling opengraph-image.tsx -
|
||||
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
|
||||
openGraph: {
|
||||
title: 'AI Models Directory | Sim',
|
||||
description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`,
|
||||
url: `${baseUrl}/models`,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'AI Models Directory | Sim',
|
||||
description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/models`,
|
||||
},
|
||||
},
|
||||
isFiltered
|
||||
)
|
||||
}
|
||||
|
||||
export default async function ModelsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
await modelsSearchParamsCache.parse(searchParams)
|
||||
|
||||
const flatModels = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
|
||||
provider.models.map((model) => ({ provider, model }))
|
||||
)
|
||||
const featuredProviders = FEATURED_PROVIDER_ORDER.map((id) =>
|
||||
MODEL_PROVIDERS_WITH_CATALOGS.find((p) => p.id === id)
|
||||
).filter((p): p is (typeof MODEL_PROVIDERS_WITH_CATALOGS)[number] => p !== undefined)
|
||||
const featuredModels = featuredProviders
|
||||
.map((provider) =>
|
||||
provider.featuredModels[0] ? { provider, model: provider.featuredModels[0] } : null
|
||||
)
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Sim AI Models Directory',
|
||||
description: `Directory of ${TOTAL_MODELS} AI models tracked in Sim across ${TOTAL_MODEL_PROVIDERS} providers.`,
|
||||
url: `${baseUrl}/models`,
|
||||
numberOfItems: TOTAL_MODELS,
|
||||
itemListElement: flatModels.map(({ provider, model }, index) => {
|
||||
const { lowPrice, highPrice } = getPricingBounds(model.pricing)
|
||||
return {
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
item: {
|
||||
'@type': 'Product',
|
||||
name: model.displayName,
|
||||
url: `${baseUrl}${model.href}`,
|
||||
description: model.summary,
|
||||
brand: provider.name,
|
||||
category: 'AI language model',
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
priceCurrency: 'USD',
|
||||
lowPrice: lowPrice.toString(),
|
||||
highPrice: highPrice.toString(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={MODELS_BREADCRUMB_JSON_LD} />
|
||||
<JsonLd data={itemListJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<section className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
<div className='flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between'>
|
||||
<h1
|
||||
id='models-heading'
|
||||
className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Compare AI Models
|
||||
</h1>
|
||||
<p className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
Browse {TOTAL_MODELS} AI models across {TOTAL_MODEL_PROVIDERS} providers. Compare
|
||||
pricing, context windows, and capabilities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
|
||||
<div className='border-[var(--border)] border-x'>
|
||||
{featuredProviders.length > 0 && (
|
||||
<>
|
||||
<nav aria-label='Featured providers' className='flex flex-col sm:flex-row'>
|
||||
{featuredProviders.map((provider) => (
|
||||
<FeaturedProviderCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</nav>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{featuredModels.length > 0 && (
|
||||
<>
|
||||
<nav aria-label='Featured models' className='flex flex-col sm:flex-row'>
|
||||
{featuredModels.map(({ provider, model }) => (
|
||||
<FeaturedModelCard key={model.id} provider={provider} model={model} />
|
||||
))}
|
||||
</nav>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ModelComparisonCharts models={ALL_CATALOG_MODELS} />
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='all-models-heading'>
|
||||
<div className='px-6 pt-10 pb-4'>
|
||||
<h2
|
||||
id='all-models-heading'
|
||||
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
All models
|
||||
</h2>
|
||||
</div>
|
||||
<ModelDirectory />
|
||||
</section>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='faq-heading'
|
||||
className='mb-8 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqItems} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MODEL_CATALOG_PROVIDERS } from '@/app/(landing)/models/utils'
|
||||
|
||||
/**
|
||||
* Luminance ceiling (Rec. 601, 0-255) above which a near-gray provider color is
|
||||
* too light to read as a dot or bar on the landing's light background. OpenAI's
|
||||
* near-white brand gray sits well above this, so it gets darkened to stay visible
|
||||
* on the timeline and comparison charts.
|
||||
*/
|
||||
const MAX_LUMINANCE = 140
|
||||
|
||||
/**
|
||||
* Only desaturated (near-gray) colors are darkened. Saturated brand colors stay
|
||||
* readable on white thanks to their hue, so a bright yellow or orange is left
|
||||
* exactly as the brand declares it.
|
||||
*/
|
||||
const MAX_GRAY_SATURATION = 0.15
|
||||
|
||||
/** Fallback when a provider declares no brand color: a readable mid-dark gray. */
|
||||
const FALLBACK_COLOR = '#666666'
|
||||
|
||||
/** Darkens a too-light near-gray color toward the readable ceiling; passes others through. */
|
||||
function clampToReadable(hex: string): string {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex)
|
||||
if (!match) return hex
|
||||
|
||||
const value = Number.parseInt(match[1], 16)
|
||||
const r = (value >> 16) & 0xff
|
||||
const g = (value >> 8) & 0xff
|
||||
const b = value & 0xff
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const saturation = max === 0 ? 0 : (max - Math.min(r, g, b)) / max
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
if (luminance <= MAX_LUMINANCE || saturation > MAX_GRAY_SATURATION) return hex
|
||||
|
||||
const factor = MAX_LUMINANCE / luminance
|
||||
const toHex = (channel: number) =>
|
||||
Math.round(channel * factor)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
const colorMap = new Map(
|
||||
MODEL_CATALOG_PROVIDERS.flatMap(
|
||||
(p): Array<[string, string]> => (p.color ? [[p.id, clampToReadable(p.color)]] : [])
|
||||
)
|
||||
)
|
||||
|
||||
/** Provider brand color, darkened when too light to read on the light background. */
|
||||
export function getProviderColor(providerId: string): string {
|
||||
return colorMap.get(providerId) ?? FALLBACK_COLOR
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getProviderColor } from '@/app/(landing)/models/components/constants'
|
||||
import type { CatalogModel } from '@/app/(landing)/models/utils'
|
||||
import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
MODEL_CATALOG_PROVIDERS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
/** Flagship providers featured in the landing-page comparison, in display order. */
|
||||
const FEATURED_COMPARISON_PROVIDER_IDS = ['anthropic', 'openai', 'google']
|
||||
|
||||
/** Max latest models pulled from each featured provider. */
|
||||
const MAX_MODELS_PER_PROVIDER = 4
|
||||
|
||||
const PROVIDER_ICON_MAP: Record<string, ComponentType<{ className?: string }>> = (() => {
|
||||
const map: Record<string, ComponentType<{ className?: string }>> = {}
|
||||
for (const provider of MODEL_CATALOG_PROVIDERS) {
|
||||
if (provider.icon) {
|
||||
map[provider.id] = provider.icon
|
||||
}
|
||||
}
|
||||
return map
|
||||
})()
|
||||
|
||||
function selectComparisonModels(models: CatalogModel[]): CatalogModel[] {
|
||||
const seen = new Set<string>()
|
||||
const result: CatalogModel[] = []
|
||||
|
||||
for (const providerId of FEATURED_COMPARISON_PROVIDER_IDS) {
|
||||
const providerModels = models
|
||||
.filter((model) => model.providerId === providerId && !model.deprecated)
|
||||
.sort((a, b) => (b.releaseDate ?? '').localeCompare(a.releaseDate ?? ''))
|
||||
|
||||
let takenForProvider = 0
|
||||
for (const model of providerModels) {
|
||||
if (takenForProvider >= MAX_MODELS_PER_PROVIDER) break
|
||||
|
||||
const nameKey = model.displayName.toLowerCase()
|
||||
if (seen.has(nameKey)) continue
|
||||
|
||||
seen.add(nameKey)
|
||||
result.push(model)
|
||||
takenForProvider += 1
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
interface ModelLabelProps {
|
||||
model: CatalogModel
|
||||
}
|
||||
|
||||
function ModelLabel({ model }: ModelLabelProps) {
|
||||
const Icon = PROVIDER_ICON_MAP[model.providerId]
|
||||
|
||||
return (
|
||||
<div className='flex w-[90px] shrink-0 items-center justify-end gap-1.5 sm:w-[140px] lg:w-[180px]'>
|
||||
{Icon && <Icon className='size-3.5 shrink-0' />}
|
||||
<span className='truncate text-[13px] text-[var(--text-primary)] leading-none tracking-[-0.01em]'>
|
||||
{model.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChartProps {
|
||||
models: CatalogModel[]
|
||||
}
|
||||
|
||||
function StackedCostChart({ models }: ChartProps) {
|
||||
const entries = models
|
||||
.reduce<Array<{ model: CatalogModel; input: number; output: number; total: number }>>(
|
||||
(acc, model) => {
|
||||
const total = model.pricing.input + model.pricing.output
|
||||
if (total > 0) {
|
||||
acc.push({ model, input: model.pricing.input, output: model.pricing.output, total })
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
.sort((a, b) => a.total - b.total)
|
||||
|
||||
const maxTotal = entries.length > 0 ? Math.max(...entries.map((e) => e.total)) : 0
|
||||
const data = { entries, maxTotal }
|
||||
|
||||
if (data.entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h3 className='text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'>
|
||||
Cost
|
||||
</h3>
|
||||
<span className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
Per 1M tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{data.entries.map(({ model, input, output, total }) => {
|
||||
const totalPct = data.maxTotal > 0 ? (total / data.maxTotal) * 100 : 0
|
||||
const inputPct = total > 0 ? (input / total) * 100 : 0
|
||||
const color = getProviderColor(model.providerId)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={model.id}
|
||||
href={model.href}
|
||||
className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ModelLabel model={model} />
|
||||
<div className='relative flex h-7 min-w-0 flex-1 items-center'>
|
||||
<div
|
||||
className='hidden h-full overflow-hidden rounded-r-[3px] sm:flex'
|
||||
style={{ width: `${Math.max(totalPct, 3)}%` }}
|
||||
>
|
||||
<div
|
||||
className='h-full'
|
||||
style={{
|
||||
width: `${inputPct}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='h-full'
|
||||
style={{
|
||||
width: `${100 - inputPct}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className='shrink-0 text-[11px] text-[var(--text-muted)] sm:ml-2.5 sm:text-xs'>
|
||||
{formatPrice(input)} input / {formatPrice(output)} output
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextWindowChart({ models }: ChartProps) {
|
||||
const entries = models
|
||||
.map((model) => ({
|
||||
model,
|
||||
value: model.contextWindow,
|
||||
}))
|
||||
.filter((e): e is { model: CatalogModel; value: number } => e.value !== null && e.value > 0)
|
||||
.sort((a, b) => a.value - b.value)
|
||||
|
||||
const maxValue = entries.length > 0 ? Math.max(...entries.map((e) => e.value)) : 0
|
||||
const data = { entries, maxValue }
|
||||
|
||||
if (data.entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h3 className='text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'>
|
||||
Context window
|
||||
</h3>
|
||||
<span className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
Max tokens
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{data.entries.map(({ model, value }) => {
|
||||
const pct = data.maxValue > 0 ? (value / data.maxValue) * 100 : 0
|
||||
const color = getProviderColor(model.providerId)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={model.id}
|
||||
href={model.href}
|
||||
className='-mx-2 flex items-center gap-3 rounded-md px-2 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ModelLabel model={model} />
|
||||
<div className='relative flex h-7 min-w-0 flex-1 items-center'>
|
||||
<div
|
||||
className='h-full rounded-r-[3px]'
|
||||
style={{
|
||||
width: `${Math.max(pct, 3)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
<span className='ml-2.5 shrink-0 text-[11px] text-[var(--text-muted)] sm:text-xs'>
|
||||
{formatTokenCount(value)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelComparisonChartsProps {
|
||||
models: CatalogModel[]
|
||||
}
|
||||
|
||||
export function ModelComparisonCharts({ models }: ModelComparisonChartsProps) {
|
||||
const comparisonModels = selectComparisonModels(models)
|
||||
|
||||
return (
|
||||
<section aria-labelledby='comparison-heading'>
|
||||
<div className='px-6 pt-10 pb-4'>
|
||||
<h2
|
||||
id='comparison-heading'
|
||||
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Compare models
|
||||
</h2>
|
||||
<p className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
Side-by-side comparison of top models across key metrics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='flex flex-col sm:flex-row'>
|
||||
<div className='flex-1 p-6'>
|
||||
<StackedCostChart models={comparisonModels} />
|
||||
</div>
|
||||
<div className='h-px w-full bg-[var(--border)] sm:h-auto sm:w-px' />
|
||||
<div className='flex-1 p-6'>
|
||||
<ContextWindowChart models={comparisonModels} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipInput, Search } from '@sim/emcn'
|
||||
import Link from 'next/link'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
|
||||
import { modelsParsers, modelsUrlKeys } from '@/app/(landing)/models/search-params'
|
||||
import {
|
||||
type CatalogModel,
|
||||
type CatalogProvider,
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const PROVIDER_OPTIONS = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
count: provider.modelCount,
|
||||
}))
|
||||
|
||||
export function ModelDirectory() {
|
||||
const [{ q: query, provider }, setParams] = useQueryStates(modelsParsers, modelsUrlKeys)
|
||||
const activeProviderId = provider || null
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
|
||||
const { filteredProviders, filteredDynamicProviders } = useMemo(() => {
|
||||
const filteredProviders = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => {
|
||||
const providerMatchesSearch =
|
||||
normalizedQuery.length > 0 && provider.searchText.includes(normalizedQuery)
|
||||
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
|
||||
|
||||
if (!providerMatchesFilter) {
|
||||
return null
|
||||
}
|
||||
|
||||
const models =
|
||||
normalizedQuery.length === 0
|
||||
? provider.models
|
||||
: provider.models.filter(
|
||||
(model) =>
|
||||
model.searchText.includes(normalizedQuery) ||
|
||||
(providerMatchesSearch && normalizedQuery.length > 0)
|
||||
)
|
||||
|
||||
if (!providerMatchesSearch && models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
models: providerMatchesSearch && normalizedQuery.length > 0 ? provider.models : models,
|
||||
}
|
||||
}).filter((provider): provider is CatalogProvider => provider !== null)
|
||||
|
||||
const filteredDynamicProviders = MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS.filter((provider) => {
|
||||
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
|
||||
if (!providerMatchesFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true
|
||||
}
|
||||
|
||||
return provider.searchText.includes(normalizedQuery)
|
||||
})
|
||||
|
||||
return {
|
||||
filteredProviders,
|
||||
filteredDynamicProviders,
|
||||
}
|
||||
}, [activeProviderId, normalizedQuery])
|
||||
|
||||
const hasResults = filteredProviders.length > 0 || filteredDynamicProviders.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 flex flex-col gap-4 px-6 sm:flex-row sm:items-center'>
|
||||
<div className='max-w-[480px] flex-1'>
|
||||
<ChipInput
|
||||
icon={Search}
|
||||
type='search'
|
||||
placeholder='Search models, providers, or capabilities…'
|
||||
value={query}
|
||||
onChange={(event) =>
|
||||
setParams(
|
||||
{ q: event.target.value },
|
||||
{ limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
}
|
||||
aria-label='Search AI models'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 flex flex-wrap gap-2 px-6'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setParams({ provider: '' })}
|
||||
className={`rounded-[5px] border px-[9px] py-0.5 text-small transition-colors ${
|
||||
activeProviderId === null
|
||||
? 'border-[var(--border-1)] bg-[var(--surface-active)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-1)] text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{PROVIDER_OPTIONS.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setParams({ provider: activeProviderId === provider.id ? '' : provider.id })
|
||||
}
|
||||
className={`rounded-[5px] border px-[9px] py-0.5 text-small transition-colors ${
|
||||
activeProviderId === provider.id
|
||||
? 'border-[var(--border-1)] bg-[var(--surface-active)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-1)] text-[var(--text-primary)] hover:bg-[var(--surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
{!hasResults ? (
|
||||
<div className='px-6 py-12 text-center'>
|
||||
<h3 className='text-[18px] text-[var(--text-primary)]'>No matches found</h3>
|
||||
<p className='mt-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
Try a provider name like OpenAI or Anthropic, or search for capabilities like
|
||||
structured outputs, reasoning, or deep research.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredProviders.map((provider, index) => (
|
||||
<section key={provider.id} aria-labelledby={`${provider.id}-heading`}>
|
||||
{index > 0 && <div className='h-px w-full bg-[var(--border)]' />}
|
||||
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='group/link flex items-center gap-3 px-6 py-4 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 rounded-xl'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3
|
||||
id={`${provider.id}-heading`}
|
||||
className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'
|
||||
>
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p className='line-clamp-1 hidden text-[12px] text-[var(--text-muted)] leading-[150%] sm:block'>
|
||||
{provider.modelCount} models · {provider.description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
|
||||
{provider.models.map((model) => (
|
||||
<ModelRow key={model.id} provider={provider} model={model} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{filteredDynamicProviders.length > 0 && (
|
||||
<section aria-labelledby='dynamic-catalogs-heading'>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='px-6 pt-8 pb-6'>
|
||||
<h3
|
||||
id='dynamic-catalogs-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[20px]'
|
||||
>
|
||||
Dynamic model catalogs
|
||||
</h3>
|
||||
<p className='mt-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
These providers load their model lists dynamically at runtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<nav aria-label='Dynamic catalog providers' className='flex flex-col lg:flex-row'>
|
||||
{filteredDynamicProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className='flex flex-1 items-center gap-3 border-[var(--border)] border-t px-6 py-4 first:border-t-0 lg:border-t-0 lg:border-l lg:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 rounded-xl'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-[14px] text-[var(--text-primary)] leading-snug'>
|
||||
{provider.name}
|
||||
</h4>
|
||||
<p className='line-clamp-1 text-[12px] text-[var(--text-muted)] leading-[150%]'>
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelRow({ provider, model }: { provider: CatalogProvider; model: CatalogModel }) {
|
||||
return (
|
||||
<>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
<Link
|
||||
href={model.href}
|
||||
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 shrink-0 rounded-xl'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<h4 className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
|
||||
{model.displayName}
|
||||
</h4>
|
||||
<p className='line-clamp-1 hidden text-[12px] text-[var(--text-muted)] leading-[150%] sm:block'>
|
||||
{model.id} · Input {formatPrice(model.pricing.input)}/1M · Output{' '}
|
||||
{formatPrice(model.pricing.output)}/1M
|
||||
{model.contextWindow ? ` · ${formatTokenCount(model.contextWindow)} context` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from 'next/link'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives/provider-icon'
|
||||
import type { CatalogModel, CatalogProvider } from '@/app/(landing)/models/utils'
|
||||
|
||||
export function FeaturedModelCard({
|
||||
provider,
|
||||
model,
|
||||
}: {
|
||||
provider: CatalogProvider
|
||||
model: CatalogModel
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={model.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='size-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
{provider.name}
|
||||
</span>
|
||||
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
||||
{model.displayName}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{model.summary}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives/provider-icon'
|
||||
import type { CatalogProvider } from '@/app/(landing)/models/utils'
|
||||
|
||||
export function FeaturedProviderCard({ provider }: { provider: CatalogProvider }) {
|
||||
return (
|
||||
<Link
|
||||
href={provider.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='size-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { FeaturedModelCard } from './featured-model-card'
|
||||
export { FeaturedProviderCard } from './featured-provider-card'
|
||||
export { ProviderIcon } from './provider-icon'
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CatalogProvider } from '@/app/(landing)/models/utils'
|
||||
|
||||
export function ProviderIcon({
|
||||
provider,
|
||||
className = 'size-12 rounded-xl',
|
||||
iconClassName = 'size-6',
|
||||
}: {
|
||||
provider: Pick<CatalogProvider, 'icon' | 'name'>
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
}) {
|
||||
const Icon = provider.icon
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={iconClassName} />
|
||||
) : (
|
||||
<span className='text-[14px] text-[var(--text-primary)]'>
|
||||
{provider.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Link from 'next/link'
|
||||
import { getProviderColor } from '@/app/(landing)/models/components/constants'
|
||||
import type { CatalogModel } from '@/app/(landing)/models/utils'
|
||||
|
||||
const SHORT_DATE_FORMAT = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function formatShortDate(date: string): string {
|
||||
try {
|
||||
return SHORT_DATE_FORMAT.format(new Date(date))
|
||||
} catch {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
interface ModelTimelineChartProps {
|
||||
models: CatalogModel[]
|
||||
providerId: string
|
||||
}
|
||||
|
||||
const ITEM_WIDTH = 150
|
||||
|
||||
export function ModelTimelineChart({ models, providerId }: ModelTimelineChartProps) {
|
||||
const entries = models
|
||||
.filter((m) => m.releaseDate !== null)
|
||||
.map((m) => ({
|
||||
model: m,
|
||||
date: new Date(m.releaseDate as string),
|
||||
dateStr: m.releaseDate as string,
|
||||
}))
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const color = getProviderColor(providerId)
|
||||
|
||||
return (
|
||||
<section aria-labelledby='timeline-heading'>
|
||||
<div className='px-6 pt-10 pb-4'>
|
||||
<h2
|
||||
id='timeline-heading'
|
||||
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Release timeline
|
||||
</h2>
|
||||
<p className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em]'>
|
||||
When each model was first publicly available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto px-6 pb-8'>
|
||||
{/* Fixed height: top labels + line + bottom labels */}
|
||||
<div
|
||||
className='relative h-[140px]'
|
||||
style={{ minWidth: `${entries.length * ITEM_WIDTH}px` }}
|
||||
>
|
||||
{/* Horizontal line - vertically centered */}
|
||||
<div className='absolute top-[70px] right-0 left-0 h-px bg-[var(--border-1)]' />
|
||||
|
||||
{entries.map(({ model, dateStr }, i) => {
|
||||
const left = i * ITEM_WIDTH + ITEM_WIDTH / 2
|
||||
const isAbove = i % 2 === 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={model.id}
|
||||
href={model.href}
|
||||
className='group absolute flex flex-col items-center'
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${ITEM_WIDTH}px`,
|
||||
marginLeft: `${-ITEM_WIDTH / 2}px`,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='-translate-x-1/2 absolute top-[64px] left-1/2 size-[12px] rounded-full opacity-[0.85] transition-[opacity,transform] duration-150 group-hover:scale-110 group-hover:opacity-100'
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Stem + label above */}
|
||||
{isAbove && (
|
||||
<div className='-translate-x-1/2 absolute bottom-[74px] left-1/2 flex flex-col items-center'>
|
||||
<div className='flex flex-col items-center gap-0.5 pb-1.5'>
|
||||
<span className='whitespace-nowrap text-[12px] text-[var(--text-primary)] leading-none tracking-[-0.01em] transition-colors group-hover:text-[var(--text-primary)]'>
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className='whitespace-nowrap text-[10px] text-[var(--text-muted)] leading-none'>
|
||||
{formatShortDate(dateStr)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className='w-px'
|
||||
style={{ height: '10px', backgroundColor: color, opacity: 0.2 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stem + label below */}
|
||||
{!isAbove && (
|
||||
<div className='-translate-x-1/2 absolute top-[75px] left-1/2 flex flex-col items-center'>
|
||||
<div
|
||||
className='w-px'
|
||||
style={{ height: '10px', backgroundColor: color, opacity: 0.2 }}
|
||||
/>
|
||||
<div className='flex flex-col items-center gap-0.5 pt-1.5'>
|
||||
<span className='whitespace-nowrap text-[12px] text-[var(--text-primary)] leading-none tracking-[-0.01em] transition-colors group-hover:text-[var(--text-primary)]'>
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className='whitespace-nowrap text-[10px] text-[var(--text-muted)] leading-none'>
|
||||
{formatShortDate(dateStr)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function ModelsNotFound() {
|
||||
return (
|
||||
<main
|
||||
id='main-content'
|
||||
className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'
|
||||
>
|
||||
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
Model not found
|
||||
</h1>
|
||||
<p className='text-[var(--text-muted)] text-lg'>
|
||||
The model you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<ChipLink variant='primary' href='/models' className='mt-3'>
|
||||
Browse models
|
||||
</ChipLink>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query params for the models directory. Shared by the
|
||||
* client directory (`useQueryStates`) and the server page (`modelsSearchParamsCache`)
|
||||
* so the filtered view is server-rendered for shareable, crawlable `?provider=`/`?q=`
|
||||
* URLs — the same SSR pattern the blog index uses.
|
||||
*
|
||||
* - `q` is the search filter; its URL write is debounced on the setter, never
|
||||
* written per keystroke.
|
||||
* - `provider` filters by provider id (`''` = all).
|
||||
*/
|
||||
export const modelsParsers = {
|
||||
q: parseAsString.withDefault(''),
|
||||
provider: parseAsString.withDefault(''),
|
||||
}
|
||||
|
||||
/** Filter/search view-state: clean URLs, no back-stack churn. */
|
||||
export const modelsUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parsing this in the page's server component opts the route into dynamic
|
||||
* rendering, which populates `useSearchParams` during the server render — so the
|
||||
* client directory's `useQueryStates` filters server-side and the initial HTML
|
||||
* ships the filtered list (crawlable, shareable), with no post-hydration swap.
|
||||
*/
|
||||
export const modelsSearchParamsCache = createSearchParamsCache(modelsParsers)
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
|
||||
|
||||
describe('model catalog capability facts', () => {
|
||||
it.concurrent(
|
||||
'shows structured outputs support and published max output tokens for gpt-4o',
|
||||
() => {
|
||||
const model = getModelBySlug('openai', 'gpt-4o')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
|
||||
|
||||
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
|
||||
expect(structuredOutputs?.value).toBe('Supported')
|
||||
expect(maxOutputTokens?.value).toBe('16k')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('preserves native structured outputs labeling for claude models', () => {
|
||||
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
|
||||
expect(structuredOutputs?.value).toBe('Supported (native)')
|
||||
})
|
||||
|
||||
it.concurrent('does not invent a max output token limit when one is not published', () => {
|
||||
expect(getEffectiveMaxOutputTokens({})).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
|
||||
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
|
||||
const generalModel = getModelBySlug('mistral', 'mistral-medium-latest')
|
||||
|
||||
expect(researchModel).not.toBeNull()
|
||||
expect(generalModel).not.toBeNull()
|
||||
|
||||
expect(researchModel?.bestFor).toContain('research workflows')
|
||||
expect(generalModel?.bestFor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,827 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
|
||||
const PROVIDER_PREFIXES: Record<string, string[]> = {
|
||||
'azure-openai': ['azure/'],
|
||||
'azure-anthropic': ['azure-anthropic/'],
|
||||
vertex: ['vertex/'],
|
||||
bedrock: ['bedrock/'],
|
||||
cerebras: ['cerebras/'],
|
||||
fireworks: ['fireworks/'],
|
||||
together: ['together/'],
|
||||
baseten: ['baseten/'],
|
||||
'ollama-cloud': ['ollama-cloud/'],
|
||||
groq: ['groq/'],
|
||||
openrouter: ['openrouter/'],
|
||||
vllm: ['vllm/'],
|
||||
}
|
||||
|
||||
const TOKEN_REPLACEMENTS: Record<string, string> = {
|
||||
ai: 'AI',
|
||||
aws: 'AWS',
|
||||
gpt: 'GPT',
|
||||
oss: 'OSS',
|
||||
llm: 'LLM',
|
||||
xai: 'xAI',
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
azure: 'Azure',
|
||||
gemini: 'Gemini',
|
||||
vertex: 'Vertex',
|
||||
groq: 'Groq',
|
||||
mistral: 'Mistral',
|
||||
deepseek: 'DeepSeek',
|
||||
cerebras: 'Cerebras',
|
||||
ollama: 'Ollama',
|
||||
bedrock: 'Bedrock',
|
||||
google: 'Google',
|
||||
moonshotai: 'Moonshot AI',
|
||||
qwen: 'Qwen',
|
||||
glm: 'GLM',
|
||||
kimi: 'Kimi',
|
||||
nova: 'Nova',
|
||||
llama: 'Llama',
|
||||
meta: 'Meta',
|
||||
cohere: 'Cohere',
|
||||
amazon: 'Amazon',
|
||||
opus: 'Opus',
|
||||
sonnet: 'Sonnet',
|
||||
haiku: 'Haiku',
|
||||
flash: 'Flash',
|
||||
preview: 'Preview',
|
||||
latest: 'Latest',
|
||||
mini: 'Mini',
|
||||
nano: 'Nano',
|
||||
pro: 'Pro',
|
||||
plus: 'Plus',
|
||||
plusplus: 'PlusPlus',
|
||||
code: 'Code',
|
||||
codex: 'Codex',
|
||||
instant: 'Instant',
|
||||
versatile: 'Versatile',
|
||||
instruct: 'Instruct',
|
||||
guard: 'Guard',
|
||||
safeguard: 'Safeguard',
|
||||
medium: 'Medium',
|
||||
small: 'Small',
|
||||
large: 'Large',
|
||||
lite: 'Lite',
|
||||
premier: 'Premier',
|
||||
premierer: 'Premier',
|
||||
micro: 'Micro',
|
||||
reasoning: 'Reasoning',
|
||||
non: 'Non',
|
||||
distill: 'Distill',
|
||||
chat: 'Chat',
|
||||
text: 'Text',
|
||||
embedding: 'Embedding',
|
||||
router: 'Router',
|
||||
}
|
||||
|
||||
const PRICE_NUMBER_FORMAT_3 = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3,
|
||||
})
|
||||
|
||||
const PRICE_NUMBER_FORMAT_4 = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
|
||||
const UPDATED_AT_DATE_FORMAT = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export interface PricingInfo {
|
||||
input: number
|
||||
cachedInput?: number
|
||||
output: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CatalogFaq {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export interface CapabilityFact {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface CatalogModel {
|
||||
id: string
|
||||
slug: string
|
||||
href: string
|
||||
displayName: string
|
||||
shortId: string
|
||||
providerId: string
|
||||
providerName: string
|
||||
providerSlug: string
|
||||
contextWindow: number | null
|
||||
releaseDate: string | null
|
||||
deprecated: boolean
|
||||
pricing: PricingInfo
|
||||
capabilities: ModelCapabilities
|
||||
capabilityTags: string[]
|
||||
summary: string
|
||||
bestFor?: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export interface CatalogProvider {
|
||||
id: string
|
||||
slug: string
|
||||
href: string
|
||||
name: string
|
||||
description: string
|
||||
summary: string
|
||||
defaultModel: string
|
||||
defaultModelDisplayName: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
color?: string
|
||||
isReseller: boolean
|
||||
contextInformationAvailable: boolean
|
||||
/** Max agent-block file attachment size in bytes when the provider exceeds the default. */
|
||||
maxFileAttachmentBytes: number | null
|
||||
providerCapabilityTags: string[]
|
||||
modelCount: number
|
||||
models: CatalogModel[]
|
||||
featuredModels: CatalogModel[]
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export function formatTokenCount(value?: number | null): string {
|
||||
if (value == null) {
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
if (value >= 1000000) {
|
||||
return `${trimTrailingZeros((value / 1000000).toFixed(2))}M`
|
||||
}
|
||||
|
||||
if (value >= 1000) {
|
||||
return `${trimTrailingZeros((value / 1000).toFixed(0))}k`
|
||||
}
|
||||
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes?: number | null): string {
|
||||
if (bytes == null) {
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
if (gb >= 1) {
|
||||
return `${trimTrailingZeros(gb.toFixed(1))}GB`
|
||||
}
|
||||
return `${Math.round(bytes / (1024 * 1024))}MB`
|
||||
}
|
||||
|
||||
export function formatPrice(price?: number | null): string {
|
||||
if (price === undefined || price === null) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
const formatter = price > 0 && price < 0.001 ? PRICE_NUMBER_FORMAT_4 : PRICE_NUMBER_FORMAT_3
|
||||
|
||||
return `$${trimTrailingZeros(formatter.format(price))}`
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(date: string): string {
|
||||
try {
|
||||
return UPDATED_AT_DATE_FORMAT.format(new Date(date))
|
||||
} catch {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCapabilityBoolean(
|
||||
value: boolean | undefined,
|
||||
{
|
||||
positive = 'Supported',
|
||||
negative = 'Not supported',
|
||||
}: {
|
||||
positive?: string
|
||||
negative?: string
|
||||
} = {}
|
||||
): string {
|
||||
return value ? positive : negative
|
||||
}
|
||||
|
||||
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
|
||||
return !capabilities.deepResearch
|
||||
}
|
||||
|
||||
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
|
||||
return capabilities.maxOutputTokens ?? null
|
||||
}
|
||||
|
||||
function trimTrailingZeros(value: string): string {
|
||||
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/--+/g, '-')
|
||||
}
|
||||
|
||||
function getProviderPrefixes(providerId: string): string[] {
|
||||
return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`]
|
||||
}
|
||||
|
||||
function stripProviderPrefix(providerId: string, modelId: string): string {
|
||||
for (const prefix of getProviderPrefixes(providerId)) {
|
||||
if (modelId.startsWith(prefix)) {
|
||||
return modelId.slice(prefix.length)
|
||||
}
|
||||
}
|
||||
|
||||
return modelId
|
||||
}
|
||||
|
||||
function stripTechnicalSuffixes(value: string): string {
|
||||
return value
|
||||
.replace(/-\d{8}-v\d+:\d+$/i, '')
|
||||
.replace(/-v\d+:\d+$/i, '')
|
||||
.replace(/-\d{8}$/i, '')
|
||||
}
|
||||
|
||||
function tokenizeModelName(value: string): string[] {
|
||||
return value
|
||||
.replace(/[./:_]+/g, '-')
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function mergeVersionTokens(tokens: string[]): string[] {
|
||||
const merged: string[] = []
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const current = tokens[index]
|
||||
const next = tokens[index + 1]
|
||||
|
||||
if (/^\d{1,2}$/.test(current) && /^\d{1,2}$/.test(next)) {
|
||||
merged.push(`${current}.${next}`)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
merged.push(current)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function formatModelToken(token: string): string {
|
||||
const normalized = token.toLowerCase()
|
||||
|
||||
if (TOKEN_REPLACEMENTS[normalized]) {
|
||||
return TOKEN_REPLACEMENTS[normalized]
|
||||
}
|
||||
|
||||
if (/^\d+b$/i.test(token)) {
|
||||
return `${token.slice(0, -1)}B`
|
||||
}
|
||||
|
||||
if (/^\d+e$/i.test(token)) {
|
||||
return `${token.slice(0, -1)}E`
|
||||
}
|
||||
|
||||
if (/^o\d+$/i.test(token)) {
|
||||
return token.toLowerCase()
|
||||
}
|
||||
|
||||
if (/^r\d+$/i.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
if (/^v\d+$/i.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
if (/^\d+\.\d+$/.test(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
if (/^[a-z]{3,}\d+$/i.test(token)) {
|
||||
const [, prefix, version] = token.match(/^([a-z]{3,})(\d+)$/i) ?? []
|
||||
if (prefix && version) {
|
||||
return `${formatModelToken(prefix)} ${version}`
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-z]\d+[a-z]$/i.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
return token.charAt(0).toUpperCase() + token.slice(1)
|
||||
}
|
||||
|
||||
function formatModelDisplayName(providerId: string, modelId: string): string {
|
||||
const shortId = stripProviderPrefix(providerId, modelId)
|
||||
const normalized = stripTechnicalSuffixes(shortId)
|
||||
const tokens = mergeVersionTokens(tokenizeModelName(normalized))
|
||||
|
||||
const displayName = tokens
|
||||
.map(formatModelToken)
|
||||
.join(' ')
|
||||
.split(/\s+/)
|
||||
.filter(
|
||||
(word, index, words) => index === 0 || word.toLowerCase() !== words[index - 1].toLowerCase()
|
||||
)
|
||||
.join(' ')
|
||||
|
||||
return displayName.replace(/^GPT (\d[\w.]*)/i, 'GPT-$1').replace(/\bGpt\b/g, 'GPT')
|
||||
}
|
||||
|
||||
function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
|
||||
const tags: string[] = []
|
||||
|
||||
if (capabilities.temperature) {
|
||||
tags.push(`Temperature ${capabilities.temperature.min}-${capabilities.temperature.max}`)
|
||||
}
|
||||
|
||||
if (capabilities.toolUsageControl) {
|
||||
tags.push('Tool choice')
|
||||
}
|
||||
|
||||
if (supportsCatalogStructuredOutputs(capabilities)) {
|
||||
tags.push('Structured outputs')
|
||||
}
|
||||
|
||||
if (capabilities.computerUse) {
|
||||
tags.push('Computer use')
|
||||
}
|
||||
|
||||
if (capabilities.deepResearch) {
|
||||
tags.push('Deep research')
|
||||
}
|
||||
|
||||
if (capabilities.reasoningEffort) {
|
||||
tags.push(`Reasoning ${capabilities.reasoningEffort.values.join(', ')}`)
|
||||
}
|
||||
|
||||
if (capabilities.verbosity) {
|
||||
tags.push(`Verbosity ${capabilities.verbosity.values.join(', ')}`)
|
||||
}
|
||||
|
||||
if (capabilities.thinking) {
|
||||
tags.push(`Thinking ${capabilities.thinking.levels.join(', ')}`)
|
||||
}
|
||||
|
||||
if (capabilities.maxOutputTokens) {
|
||||
tags.push(`Max output ${formatTokenCount(capabilities.maxOutputTokens)}`)
|
||||
}
|
||||
|
||||
if (capabilities.memory === false) {
|
||||
tags.push('Memory off')
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
function buildBestForLine(model: {
|
||||
pricing: PricingInfo
|
||||
capabilities: ModelCapabilities
|
||||
contextWindow: number | null
|
||||
}): string | null {
|
||||
const { pricing, capabilities, contextWindow } = model
|
||||
|
||||
if (capabilities.deepResearch) {
|
||||
return 'Best for multi-step research workflows and agent-led web investigation.'
|
||||
}
|
||||
|
||||
if (capabilities.reasoningEffort || capabilities.thinking) {
|
||||
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
|
||||
}
|
||||
|
||||
if (contextWindow && contextWindow >= 1000000) {
|
||||
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
|
||||
}
|
||||
|
||||
if (capabilities.nativeStructuredOutputs) {
|
||||
return 'Best for production workflows that need reliable typed outputs.'
|
||||
}
|
||||
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildModelSummary(
|
||||
providerName: string,
|
||||
displayName: string,
|
||||
pricing: PricingInfo,
|
||||
contextWindow: number | null,
|
||||
capabilityTags: string[]
|
||||
): string {
|
||||
const parts = [
|
||||
`${displayName} is a ${providerName} model tracked in Sim.`,
|
||||
contextWindow ? `It supports a ${formatTokenCount(contextWindow)} token context window.` : null,
|
||||
`Pricing starts at ${formatPrice(pricing.input)}/1M input tokens and ${formatPrice(pricing.output)}/1M output tokens.`,
|
||||
capabilityTags.length > 0
|
||||
? `Key capabilities include ${capabilityTags.slice(0, 3).join(', ')}.`
|
||||
: null,
|
||||
]
|
||||
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function computeModelRelevanceScore(model: CatalogModel): number {
|
||||
return (
|
||||
(model.capabilities.reasoningEffort ? 10 : 0) +
|
||||
(model.capabilities.thinking ? 10 : 0) +
|
||||
(model.capabilities.deepResearch ? 8 : 0) +
|
||||
(model.capabilities.nativeStructuredOutputs ? 4 : 0) +
|
||||
(model.contextWindow ?? 0) / 100000
|
||||
)
|
||||
}
|
||||
|
||||
function compareModelsByRelevance(a: CatalogModel, b: CatalogModel): number {
|
||||
return computeModelRelevanceScore(b) - computeModelRelevanceScore(a)
|
||||
}
|
||||
|
||||
const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
const providerSlug = slugify(provider.id)
|
||||
const providerDisplayName = provider.name
|
||||
const providerCapabilityTags = buildCapabilityTags(provider.capabilities ?? {})
|
||||
|
||||
const models: CatalogModel[] = provider.models.map((model) => {
|
||||
const shortId = stripProviderPrefix(provider.id, model.id)
|
||||
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
|
||||
const capabilityTags = buildCapabilityTags(mergedCapabilities)
|
||||
const bestFor = buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
})
|
||||
const displayName = formatModelDisplayName(provider.id, model.id)
|
||||
const modelSlug = slugify(shortId)
|
||||
const href = `/models/${providerSlug}/${modelSlug}`
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
slug: modelSlug,
|
||||
href,
|
||||
displayName,
|
||||
shortId,
|
||||
providerId: provider.id,
|
||||
providerName: providerDisplayName,
|
||||
providerSlug,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
releaseDate: model.releaseDate ?? null,
|
||||
deprecated: model.deprecated ?? false,
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
capabilityTags,
|
||||
summary: buildModelSummary(
|
||||
providerDisplayName,
|
||||
displayName,
|
||||
model.pricing,
|
||||
model.contextWindow ?? null,
|
||||
capabilityTags
|
||||
),
|
||||
...(bestFor ? { bestFor } : {}),
|
||||
searchText: [
|
||||
provider.name,
|
||||
providerDisplayName,
|
||||
provider.id,
|
||||
provider.description,
|
||||
model.id,
|
||||
shortId,
|
||||
displayName,
|
||||
capabilityTags.join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase(),
|
||||
}
|
||||
})
|
||||
|
||||
const defaultModelDisplayName =
|
||||
models.find((model) => model.id === provider.defaultModel)?.displayName ||
|
||||
(provider.defaultModel ? formatModelDisplayName(provider.id, provider.defaultModel) : 'Dynamic')
|
||||
|
||||
const featuredModels = [...models].sort(compareModelsByRelevance).slice(0, 6)
|
||||
|
||||
return {
|
||||
id: provider.id,
|
||||
slug: providerSlug,
|
||||
href: `/models/${providerSlug}`,
|
||||
name: providerDisplayName,
|
||||
description: provider.description,
|
||||
summary: `${providerDisplayName} has ${models.length} tracked model${models.length === 1 ? '' : 's'} in Sim with pricing, context window, and capability metadata.`,
|
||||
defaultModel: provider.defaultModel,
|
||||
defaultModelDisplayName,
|
||||
icon: provider.icon,
|
||||
color: provider.color,
|
||||
isReseller: provider.isReseller ?? false,
|
||||
contextInformationAvailable: provider.contextInformationAvailable !== false,
|
||||
maxFileAttachmentBytes: provider.fileAttachment?.maxBytes ?? null,
|
||||
providerCapabilityTags,
|
||||
modelCount: models.length,
|
||||
models,
|
||||
featuredModels,
|
||||
searchText: [
|
||||
provider.name,
|
||||
provider.id,
|
||||
provider.description,
|
||||
provider.defaultModel,
|
||||
defaultModelDisplayName,
|
||||
providerCapabilityTags.join(' '),
|
||||
models.map((model) => model.displayName).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase(),
|
||||
} satisfies CatalogProvider
|
||||
})
|
||||
|
||||
function assertUniqueGeneratedRoutes(providers: CatalogProvider[]): void {
|
||||
const seenProviderHrefs = new Map<string, string>()
|
||||
const seenModelHrefs = new Map<string, string>()
|
||||
|
||||
for (const provider of providers) {
|
||||
const existingProvider = seenProviderHrefs.get(provider.href)
|
||||
if (existingProvider) {
|
||||
throw new Error(
|
||||
`Duplicate provider route detected: ${provider.href} for ${provider.id} and ${existingProvider}`
|
||||
)
|
||||
}
|
||||
seenProviderHrefs.set(provider.href, provider.id)
|
||||
|
||||
for (const model of provider.models) {
|
||||
const existingModel = seenModelHrefs.get(model.href)
|
||||
if (existingModel) {
|
||||
throw new Error(
|
||||
`Duplicate model route detected: ${model.href} for ${model.id} and ${existingModel}`
|
||||
)
|
||||
}
|
||||
seenModelHrefs.set(model.href, model.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertUniqueGeneratedRoutes(rawProviders)
|
||||
|
||||
export const MODEL_CATALOG_PROVIDERS: CatalogProvider[] = rawProviders
|
||||
export const MODEL_PROVIDERS_WITH_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
|
||||
(provider) => provider.models.length > 0 && !provider.isReseller
|
||||
)
|
||||
export const MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
|
||||
(provider) => provider.models.length === 0
|
||||
)
|
||||
export const ALL_CATALOG_MODELS = MODEL_PROVIDERS_WITH_CATALOGS.flatMap(
|
||||
(provider) => provider.models
|
||||
)
|
||||
export const TOTAL_MODEL_PROVIDERS = MODEL_CATALOG_PROVIDERS.length
|
||||
export const TOTAL_MODELS = ALL_CATALOG_MODELS.length
|
||||
export const MAX_CONTEXT_WINDOW = Math.max(
|
||||
...ALL_CATALOG_MODELS.map((model) => model.contextWindow ?? 0)
|
||||
)
|
||||
export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map(
|
||||
(provider) => provider.name
|
||||
)
|
||||
|
||||
export function getPricingBounds(pricing: PricingInfo): { lowPrice: number; highPrice: number } {
|
||||
return {
|
||||
lowPrice: Math.min(
|
||||
pricing.input,
|
||||
pricing.output,
|
||||
...(pricing.cachedInput !== undefined ? [pricing.cachedInput] : [])
|
||||
),
|
||||
highPrice: Math.max(pricing.input, pricing.output),
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderBySlug(providerSlug: string): CatalogProvider | null {
|
||||
return MODEL_CATALOG_PROVIDERS.find((provider) => provider.slug === providerSlug) ?? null
|
||||
}
|
||||
|
||||
export function getModelBySlug(providerSlug: string, modelSlug: string): CatalogModel | null {
|
||||
const provider = getProviderBySlug(providerSlug)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
|
||||
return provider.models.find((model) => model.slug === modelSlug) ?? null
|
||||
}
|
||||
|
||||
export function getRelatedModels(targetModel: CatalogModel, limit = 6): CatalogModel[] {
|
||||
const provider = MODEL_PROVIDERS_WITH_CATALOGS.find(
|
||||
(entry) => entry.id === targetModel.providerId
|
||||
)
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
|
||||
const targetTokens = new Set(tokenizeModelName(stripTechnicalSuffixes(targetModel.shortId)))
|
||||
|
||||
const scored = provider.models.reduce<Array<{ model: CatalogModel; score: number }>>(
|
||||
(acc, model) => {
|
||||
if (model.id === targetModel.id) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const modelTokens = tokenizeModelName(stripTechnicalSuffixes(model.shortId))
|
||||
const sharedTokenCount = modelTokens.filter((token) => targetTokens.has(token)).length
|
||||
const sharedCapabilityCount = model.capabilityTags.filter((tag) =>
|
||||
targetModel.capabilityTags.includes(tag)
|
||||
).length
|
||||
|
||||
acc.push({
|
||||
model,
|
||||
score: sharedTokenCount * 2 + sharedCapabilityCount + (model.contextWindow ?? 0) / 1000000,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ model }) => model)
|
||||
}
|
||||
|
||||
export function buildProviderFaqs(provider: CatalogProvider): CatalogFaq[] {
|
||||
const cheapestModel = getCheapestProviderModel(provider)
|
||||
const largestContextModel = getLargestContextProviderModel(provider)
|
||||
|
||||
const toolUseModels = provider.models.filter(
|
||||
(m) =>
|
||||
m.capabilities.toolUsageControl !== undefined ||
|
||||
provider.providerCapabilityTags.includes('Tool Use')
|
||||
)
|
||||
|
||||
const faqs: CatalogFaq[] = [
|
||||
{
|
||||
question: `What ${provider.name} models are available in Sim?`,
|
||||
answer: `Sim currently tracks ${provider.modelCount} ${provider.name} model${provider.modelCount === 1 ? '' : 's'} including ${provider.models
|
||||
.slice(0, 6)
|
||||
.map((model) => model.displayName)
|
||||
.join(', ')}${provider.modelCount > 6 ? ', and more' : ''}.`,
|
||||
},
|
||||
{
|
||||
question: `What is the default ${provider.name} model in Sim?`,
|
||||
answer: provider.defaultModel
|
||||
? `${provider.defaultModelDisplayName} is the default ${provider.name} model in Sim.`
|
||||
: `${provider.name} does not have a fixed default model in the public catalog because models are loaded dynamically.`,
|
||||
},
|
||||
{
|
||||
question: `What is the cheapest ${provider.name} model tracked in Sim?`,
|
||||
answer: cheapestModel
|
||||
? `${cheapestModel.displayName} currently has the lowest listed input price at ${formatPrice(
|
||||
cheapestModel.pricing.input
|
||||
)}/1M tokens.`
|
||||
: `Sim does not currently expose a fixed public pricing table for ${provider.name} models on this page.`,
|
||||
},
|
||||
{
|
||||
question: `Which ${provider.name} model has the largest context window?`,
|
||||
answer: largestContextModel?.contextWindow
|
||||
? `${largestContextModel.displayName} currently has the largest listed context window at ${formatTokenCount(
|
||||
largestContextModel.contextWindow
|
||||
)} tokens.`
|
||||
: `Context window details are not fully available for every ${provider.name} model in the public catalog.`,
|
||||
},
|
||||
]
|
||||
|
||||
if (toolUseModels.length > 0) {
|
||||
faqs.push({
|
||||
question: `Which ${provider.name} models support tool use and function calling in Sim?`,
|
||||
answer:
|
||||
toolUseModels.length === provider.modelCount
|
||||
? `All ${provider.name} models in Sim support tool use and function calling, allowing agents to invoke external APIs, query databases, and run custom actions.`
|
||||
: `${toolUseModels
|
||||
.slice(0, 5)
|
||||
.map((m) => m.displayName)
|
||||
.join(
|
||||
', '
|
||||
)}${toolUseModels.length > 5 ? ', and others' : ''} support tool use and function calling in Sim, enabling agents to invoke external APIs and run custom actions.`,
|
||||
})
|
||||
}
|
||||
|
||||
return faqs
|
||||
}
|
||||
|
||||
export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel): CatalogFaq[] {
|
||||
const faqs: CatalogFaq[] = [
|
||||
{
|
||||
question: `What is ${model.displayName}?`,
|
||||
answer: `${model.displayName} is a ${provider.name} model available in Sim. ${model.summary}`,
|
||||
},
|
||||
{
|
||||
question: `How much does ${model.displayName} cost?`,
|
||||
answer: `${model.displayName} is listed at ${formatPrice(model.pricing.input)}/1M input tokens${model.pricing.cachedInput !== undefined ? `, ${formatPrice(model.pricing.cachedInput)}/1M cached input tokens` : ''}, and ${formatPrice(model.pricing.output)}/1M output tokens.`,
|
||||
},
|
||||
{
|
||||
question: `What is the context window for ${model.displayName}?`,
|
||||
answer: model.contextWindow
|
||||
? `${model.displayName} supports a context window of ${formatTokenCount(model.contextWindow)} tokens in Sim. In an agent, this determines how much conversation history, tool outputs, and retrieved documents the model can hold in a single call.`
|
||||
: `A public context window value is not currently tracked for ${model.displayName}.`,
|
||||
},
|
||||
{
|
||||
question: `What capabilities does ${model.displayName} support?`,
|
||||
answer:
|
||||
model.capabilityTags.length > 0
|
||||
? `${model.displayName} supports the following capabilities in Sim: ${model.capabilityTags.join(', ')}.`
|
||||
: `${model.displayName} supports standard text generation in Sim. No additional capability flags such as tool use or structured outputs are currently tracked for this model.`,
|
||||
},
|
||||
]
|
||||
|
||||
if (model.bestFor) {
|
||||
faqs.push({
|
||||
question: `What is ${model.displayName} best used for?`,
|
||||
answer: `${model.bestFor} When used in a Sim workflow, it can be selected in any Agent block from the model picker.`,
|
||||
})
|
||||
}
|
||||
|
||||
return faqs
|
||||
}
|
||||
|
||||
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
|
||||
const { capabilities } = model
|
||||
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Temperature',
|
||||
value: capabilities.temperature
|
||||
? `${capabilities.temperature.min} to ${capabilities.temperature.max}`
|
||||
: 'Not configurable',
|
||||
},
|
||||
{
|
||||
label: 'Reasoning effort',
|
||||
value: capabilities.reasoningEffort
|
||||
? capabilities.reasoningEffort.values.join(', ')
|
||||
: 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Verbosity',
|
||||
value: capabilities.verbosity ? capabilities.verbosity.values.join(', ') : 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Thinking levels',
|
||||
value: capabilities.thinking
|
||||
? `${capabilities.thinking.levels.join(', ')}${
|
||||
capabilities.thinking.default ? ` (default: ${capabilities.thinking.default})` : ''
|
||||
}`
|
||||
: 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Structured outputs',
|
||||
value: supportsStructuredOutputs
|
||||
? capabilities.nativeStructuredOutputs
|
||||
? 'Supported (native)'
|
||||
: 'Supported'
|
||||
: 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Tool choice',
|
||||
value: formatCapabilityBoolean(capabilities.toolUsageControl),
|
||||
},
|
||||
{
|
||||
label: 'Computer use',
|
||||
value: formatCapabilityBoolean(capabilities.computerUse),
|
||||
},
|
||||
{
|
||||
label: 'Deep research',
|
||||
value: formatCapabilityBoolean(capabilities.deepResearch),
|
||||
},
|
||||
{
|
||||
label: 'Memory support',
|
||||
value: capabilities.memory === false ? 'Disabled' : 'Supported',
|
||||
},
|
||||
{
|
||||
label: 'Max output tokens',
|
||||
value: capabilities.maxOutputTokens
|
||||
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
|
||||
: 'Not published',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getCheapestProviderModel(provider: CatalogProvider): CatalogModel | null {
|
||||
if (provider.models.length === 0) return null
|
||||
return provider.models.reduce((min, m) => (m.pricing.input < min.pricing.input ? m : min))
|
||||
}
|
||||
|
||||
export function getLargestContextProviderModel(provider: CatalogProvider): CatalogModel | null {
|
||||
if (provider.models.length === 0) return null
|
||||
return provider.models.reduce((max, m) =>
|
||||
(m.contextWindow ?? 0) > (max.contextWindow ?? 0) ? m : max
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user