diff --git a/.gitignore b/.gitignore index 5db139fbe0..1960629a33 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ llmgateway_data .claude/scheduled_tasks.lock *.http *.txt +!apps/ui/public/llms.txt docker-compose-override-*.yml benchmark_results.json *.pdf diff --git a/apps/ui/next.config.ts b/apps/ui/next.config.ts index 52766abc5a..1bc7488b22 100644 --- a/apps/ui/next.config.ts +++ b/apps/ui/next.config.ts @@ -117,10 +117,8 @@ const nextConfig: NextConfig = { }, async rewrites() { return [ - { - source: "/llms.txt", - destination: "https://docs.llmgateway.io/llms.txt", - }, + // /llms.txt is served as a static file from public/ (which takes + // precedence over rewrites), so it is intentionally not proxied here. { source: "/llms-full.txt", destination: "https://docs.llmgateway.io/llms-full.txt", diff --git a/apps/ui/public/llms.txt b/apps/ui/public/llms.txt new file mode 100644 index 0000000000..f752556b4e --- /dev/null +++ b/apps/ui/public/llms.txt @@ -0,0 +1,17 @@ +# LLM Gateway + +> LLM Gateway is a unified API for routing requests across OpenAI, Anthropic, Google, Meta, Mistral, DeepSeek and 35+ other LLM providers. Use one OpenAI-compatible API and one key to access every model, with usage analytics, cost tracking, caching, and automatic failover. Open source (AGPLv3) and self-hostable. + +## Key pages + +- [Models](https://llmgateway.io/models): Full catalog of supported LLMs with pricing, context windows, and capabilities. +- [Model release timeline](https://llmgateway.io/timeline): When each LLM was released by its provider and when it was added to LLM Gateway — a reference for AI model release dates (GPT, Claude, Gemini, Llama, Mistral, DeepSeek). +- [Pricing](https://llmgateway.io/pricing): Pay-as-you-go credits with a 5% platform fee, free Bring-Your-Own-Keys, and enterprise plans. +- [Providers](https://llmgateway.io/providers): Every supported provider and the models each offers. +- [Docs](https://docs.llmgateway.io): API reference and integration guides. +- [Compare](https://llmgateway.io/compare/open-router): How LLM Gateway compares to OpenRouter, LiteLLM, Portkey, and the Vercel AI Gateway. + +## Notes + +- New models are typically available within 48 hours of their provider release. +- The API is OpenAI-compatible; point your existing SDK at the gateway base URL. diff --git a/apps/ui/src/app/sitemap.ts b/apps/ui/src/app/sitemap.ts index 680776cb3c..3fadf176f6 100644 --- a/apps/ui/src/app/sitemap.ts +++ b/apps/ui/src/app/sitemap.ts @@ -20,6 +20,46 @@ function slugify(label: string) { // "changed just now" on every crawl, which trains search engines to ignore it. const buildDate = new Date(); +// Most recent provider release date across the catalog. Used as the timeline +// page's `lastModified` so it reflects real content freshness (a new model) +// rather than the deploy time. +const latestModelReleaseDate = (() => { + let latest = new Date(0); + for (const model of modelDefinitions) { + if ("releasedAt" in model && model.releasedAt) { + const date = new Date(model.releasedAt); + if (!Number.isNaN(date.getTime()) && date.getTime() > latest.getTime()) { + latest = date; + } + } + } + return latest.getTime() === 0 ? buildDate : latest; +})(); + +// Distinct release years across the catalog plus the latest release date within +// each year, used to emit /timeline/{year} hub children. Using the per-year +// latest release as `lastModified` keeps historical year pages from reporting a +// change on every deploy. +const timelineYears = (() => { + const latestByYear = new Map(); + for (const model of modelDefinitions) { + if ("releasedAt" in model && model.releasedAt) { + const date = new Date(model.releasedAt); + if (Number.isNaN(date.getTime())) { + continue; + } + const year = date.getUTCFullYear(); + const current = latestByYear.get(year); + if (!current || date.getTime() > current.getTime()) { + latestByYear.set(year, date); + } + } + } + return Array.from(latestByYear.entries()) + .map(([year, lastModified]) => ({ year, lastModified })) + .sort((a, b) => b.year - a.year); +})(); + export default async function sitemap(): Promise { const baseUrl = "https://llmgateway.io"; @@ -102,9 +142,9 @@ export default async function sitemap(): Promise { }, { url: `${baseUrl}/timeline`, - lastModified: buildDate, - changeFrequency: "monthly", - priority: 0.5, + lastModified: latestModelReleaseDate, + changeFrequency: "weekly", + priority: 0.8, }, { url: `${baseUrl}/brand`, @@ -399,8 +439,20 @@ export default async function sitemap(): Promise { priority: 0.7, })); + // Per-year timeline hub children (/timeline/{year}) + const currentYear = buildDate.getFullYear(); + const timelineYearPages: MetadataRoute.Sitemap = timelineYears.map( + ({ year, lastModified }) => ({ + url: `${baseUrl}/timeline/${year}`, + lastModified, + changeFrequency: year === currentYear ? "weekly" : "monthly", + priority: year === currentYear ? 0.7 : 0.6, + }), + ); + return [ ...staticPages, + ...timelineYearPages, ...modelPages, ...providerPages, ...featurePages, diff --git a/apps/ui/src/app/timeline/[year]/opengraph-image.tsx b/apps/ui/src/app/timeline/[year]/opengraph-image.tsx new file mode 100644 index 0000000000..95fa371385 --- /dev/null +++ b/apps/ui/src/app/timeline/[year]/opengraph-image.tsx @@ -0,0 +1,146 @@ +import { ImageResponse } from "next/og"; + +import { models as modelDefinitions } from "@llmgateway/models"; + +export const size = { + width: 1200, + height: 630, +}; + +export const contentType = "image/png"; + +export const alt = "AI model release timeline by year — LLM Gateway"; + +interface ImageProps { + params: Promise<{ year: string }>; +} + +export default async function TimelineYearOgImage({ params }: ImageProps) { + const { year } = await params; + + const count = modelDefinitions.filter((model) => { + if (!("releasedAt" in model) || !model.releasedAt) { + return false; + } + const date = new Date(model.releasedAt); + return ( + !Number.isNaN(date.getTime()) && String(date.getUTCFullYear()) === year + ); + }).length; + + return new ImageResponse( + ( +
+
+ + + + +
+ + LLM Gateway + + • + Release Timeline +
+
+ +
+

+ AI models released in {year} +

+

+ Provider release dates for every {year} LLM — and when each landed + on LLM Gateway. +

+
+ +
+
+ {count} + + models released in {year} + +
+ + llmgateway.io/timeline/{year} + +
+
+ ), + size, + ); +} diff --git a/apps/ui/src/app/timeline/[year]/page.tsx b/apps/ui/src/app/timeline/[year]/page.tsx new file mode 100644 index 0000000000..60a55f7535 --- /dev/null +++ b/apps/ui/src/app/timeline/[year]/page.tsx @@ -0,0 +1,367 @@ +import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import Footer from "@/components/landing/footer"; +import { Navbar } from "@/components/landing/navbar"; +import { TimelineList } from "@/components/timeline/timeline-list"; +import { Badge } from "@/lib/components/badge"; +import { Button } from "@/lib/components/button"; +import { fetchModels } from "@/lib/fetch-models"; +import { serializeJsonLd } from "@/lib/json-ld"; +import { + buildTimelineModels, + buildTimelineStats, + buildYearFaqs, + formatDate, + getTimelineYears, + getYearSummaries, + isoDate, + modelsForYear, +} from "@/lib/timeline-data"; + +import type { Metadata } from "next"; + +const BASE_URL = "https://llmgateway.io"; + +interface YearPageProps { + params: Promise<{ year: string }>; +} + +export async function generateMetadata({ + params, +}: YearPageProps): Promise { + const { year } = await params; + + if (!/^\d{4}$/.test(year)) { + return { title: "Model Timeline" }; + } + + const title = `LLMs Released in ${year} — AI Model Release Dates`; + const description = `Every large language model released in ${year}: provider release dates for GPT, Claude, Gemini, Llama, Mistral, DeepSeek and more, with the date each was added to LLM Gateway.`; + + return { + title, + description, + alternates: { + canonical: `/timeline/${year}`, + }, + openGraph: { + title, + description, + type: "website", + url: `${BASE_URL}/timeline/${year}`, + }, + twitter: { + card: "summary_large_image", + title, + description, + }, + }; +} + +export default async function TimelineYearPage({ params }: YearPageProps) { + const { year } = await params; + + if (!/^\d{4}$/.test(year)) { + notFound(); + } + + const models = await fetchModels(); + const timelineModels = buildTimelineModels(models); + const years = getTimelineYears(timelineModels); + + if (!years.includes(year)) { + notFound(); + } + + const stats = buildTimelineStats(timelineModels, models); + const yearModels = modelsForYear(timelineModels, year); + const summary = getYearSummaries(timelineModels).find( + (item) => item.year === year, + )!; + const faqs = buildYearFaqs(year, yearModels, summary); + + // years are newest-first; "newer" sits before the current year in the list + const currentIndex = years.indexOf(year); + const newerYear = currentIndex > 0 ? years[currentIndex - 1] : null; + const olderYear = + currentIndex < years.length - 1 ? years[currentIndex + 1] : null; + + const itemListSchema = { + "@context": "https://schema.org", + "@type": "ItemList", + name: `LLMs released in ${year}`, + numberOfItems: yearModels.length, + itemListElement: yearModels.map((model, index) => ({ + "@type": "ListItem", + position: index + 1, + name: `${model.name} (${model.providerName})`, + url: `${BASE_URL}/models/${encodeURIComponent(model.id)}`, + })), + }; + + const datasetSchema = { + "@context": "https://schema.org", + "@type": "Dataset", + name: `LLM releases in ${year}`, + description: `Large language models released in ${year}, with provider release dates and the date each was added to LLM Gateway.`, + url: `${BASE_URL}/timeline/${year}`, + isPartOf: { "@type": "Dataset", "@id": `${BASE_URL}/timeline` }, + temporalCoverage: `${year}-01-01/${year}-12-31`, + creator: { "@type": "Organization", name: "LLM Gateway", url: BASE_URL }, + isAccessibleForFree: true, + ...(summary.latestInYearAt + ? { dateModified: summary.latestInYearAt.slice(0, 10) } + : {}), + }; + + const breadcrumbSchema = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: BASE_URL }, + { + "@type": "ListItem", + position: 2, + name: "Model Timeline", + item: `${BASE_URL}/timeline`, + }, + { + "@type": "ListItem", + position: 3, + name: year, + item: `${BASE_URL}/timeline/${year}`, + }, + ], + }; + + const faqSchema = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { "@type": "Answer", text: faq.answer }, + })), + }; + + return ( + <> + " can't break out of + * the tag. Use this for any dangerouslySetInnerHTML JSON-LD payload built from + * data we don't fully control. + */ +export function serializeJsonLd(schema: unknown): string { + return JSON.stringify(schema).replace(/ = { + alibaba: "Alibaba", + anthropic: "Anthropic", + atlascloud: "AtlasCloud", + bytedance: "ByteDance", + deepseek: "DeepSeek", + elevenlabs: "ElevenLabs", + google: "Google", + llmgateway: "LLM Gateway", + meta: "Meta", + minimax: "MiniMax", + mistral: "Mistral", + moonshot: "Moonshot AI", + nvidia: "NVIDIA", + openai: "OpenAI", + perplexity: "Perplexity", + reve: "Reve", + sakana: "Sakana AI", + xai: "xAI", + xiaomi: "Xiaomi", + zai: "Z.AI", +}; + +export function familyLabel(family: string): string { + if (FAMILY_LABELS[family]) { + return FAMILY_LABELS[family]; + } + const def = getProviderDefinition(family); + if (def?.name) { + return def.name; + } + return family.charAt(0).toUpperCase() + family.slice(1); +} + +// Rough heuristic to highlight major / flagship models. +const SIGNIFICANT_KEYWORDS = [ + "gpt-4", + "gpt-5", + "gpt-3.5", + "o1", + "o3", + "o4", + "claude-3", + "claude 3", + "claude-4", + "claude 4", + "sonnet", + "opus", + "haiku", + "gemini", + "llama", + "mixtral", + "mistral-large", + "deepseek", + "qwen", + "grok", + "kimi", +]; + +function isSignificant(id: string, name: string): boolean { + const haystack = `${id} ${name}`.toLowerCase(); + return SIGNIFICANT_KEYWORDS.some((k) => haystack.includes(k)); +} + +function clampAdded(createdAt: string | null): string | null { + if (!createdAt) { + return null; + } + const date = new Date(createdAt); + if (date.getTime() < GATEWAY_LAUNCH.getTime()) { + return GATEWAY_LAUNCH.toISOString(); + } + return date.toISOString(); +} + +/** + * Map raw API models to serializable timeline entries, sorted newest-first by + * provider release date. Runs on the server so the JSON-LD and the initial HTML + * are built from the exact same data. + */ +export function buildTimelineModels(models: ApiModel[]): TimelineModel[] { + return models + .filter((model) => model.status !== "inactive") + .map((model) => { + const name = model.name ?? String(model.id); + return { + id: String(model.id), + name, + family: String(model.family), + providerName: familyLabel(String(model.family)), + releasedAt: model.releasedAt + ? new Date(model.releasedAt).toISOString() + : null, + addedAt: clampAdded(model.createdAt ?? null), + significant: isSignificant(String(model.id), name), + }; + }) + .sort((a, b) => { + const aTime = a.releasedAt ? new Date(a.releasedAt).getTime() : 0; + const bTime = b.releasedAt ? new Date(b.releasedAt).getTime() : 0; + return bTime - aTime; + }); +} + +export function buildTimelineStats( + models: TimelineModel[], + raw: ApiModel[], +): TimelineStats { + const providerIds = new Set(); + for (const model of raw) { + // Only count providers that serve at least one active model, matching the + // active-model filtering in buildTimelineModels so stats stay consistent. + if (model.status === "inactive") { + continue; + } + for (const mapping of model.mappings ?? []) { + if (mapping.providerId && mapping.providerId !== "llmgateway") { + providerIds.add(mapping.providerId); + } + } + } + + const families = new Set(models.map((m) => m.family)); + + const released = models + .map((m) => m.releasedAt) + .filter((d): d is string => Boolean(d)) + .sort(); + + const firstYear = released.length + ? new Date(released[0]).getUTCFullYear() + : null; + const latestReleasedAt = released.length + ? released[released.length - 1] + : null; + const latestModelName = + models.find((m) => m.releasedAt === latestReleasedAt)?.name ?? null; + + return { + totalModels: models.length, + totalProviders: providerIds.size, + totalFamilies: families.size, + firstYear, + latestReleasedAt, + latestModelName, + }; +} + +const dateFormatter = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", +}); + +const monthFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); + +/** Human date, formatted with an explicit locale so SSR/CSR output matches. */ +export function formatDate(iso: string | null): string { + if (!iso) { + return "Unknown"; + } + return dateFormatter.format(new Date(iso)); +} + +export function formatMonth(iso: string | null): string { + if (!iso) { + return "Unknown"; + } + return monthFormatter.format(new Date(iso)); +} + +/** `YYYY-MM-DD` value for a machine-readable