diff --git a/apps/ui/content-collections.ts b/apps/ui/content-collections.ts index 1f1f5ca4bd..0e404ffd81 100644 --- a/apps/ui/content-collections.ts +++ b/apps/ui/content-collections.ts @@ -21,6 +21,29 @@ const changelog = defineCollection({ }), }); +const blog = defineCollection({ + name: "blog", + directory: "src/content/blog", + include: "**/*.md", + schema: z.object({ + id: z.string(), + slug: z.string(), + date: z.string(), + title: z.string(), + summary: z.string(), + draft: z.boolean().optional(), + categories: z.array(z.string()).default([]), + image: z + .object({ + src: z.string(), + alt: z.string(), + width: z.number(), + height: z.number(), + }) + .optional(), + }), +}); + export default defineConfig({ - collections: [changelog], + collections: [changelog, blog], }); diff --git a/apps/ui/next.config.ts b/apps/ui/next.config.ts index ecfe75fafe..f325f341f4 100644 --- a/apps/ui/next.config.ts +++ b/apps/ui/next.config.ts @@ -12,6 +12,30 @@ const nextConfig: NextConfig = { // devtoolSegmentExplorer: true, // globalNotFound: true, // }, + async redirects() { + return [ + { + source: "/docs", + destination: "https://docs.llmgateway.com", + permanent: true, + }, + { + source: "/discord", + destination: "https://discord.gg/3u7jpXf36B", + permanent: true, + }, + { + source: "/github", + destination: "https://github.com/theopenco/llmgateway", + permanent: true, + }, + { + source: "/twitter", + destination: "https://twitter.com/llmgateway", + permanent: true, + }, + ]; + }, }; // withContentCollections must be the outermost plugin diff --git a/apps/ui/public/blog/blog-introducing-llm-gateway.png b/apps/ui/public/blog/blog-introducing-llm-gateway.png new file mode 100644 index 0000000000..e089e5dd7d Binary files /dev/null and b/apps/ui/public/blog/blog-introducing-llm-gateway.png differ diff --git a/apps/ui/public/blog/custom-openai-compatible-providers.png b/apps/ui/public/blog/custom-openai-compatible-providers.png new file mode 100644 index 0000000000..f6b565dcf7 Binary files /dev/null and b/apps/ui/public/blog/custom-openai-compatible-providers.png differ diff --git a/apps/ui/public/blog/how-to-self-host-llm-gateway.png b/apps/ui/public/blog/how-to-self-host-llm-gateway.png new file mode 100644 index 0000000000..1e5dd4c1d3 Binary files /dev/null and b/apps/ui/public/blog/how-to-self-host-llm-gateway.png differ diff --git a/apps/ui/src/app/blog/[slug]/page.tsx b/apps/ui/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000000..2e5e7964b9 --- /dev/null +++ b/apps/ui/src/app/blog/[slug]/page.tsx @@ -0,0 +1,123 @@ +import { allBlogs, type Blog } from "content-collections"; +import { ArrowLeftIcon } from "lucide-react"; +import Markdown from "markdown-to-jsx"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import Footer from "@/components/landing/footer"; +import { HeroRSC } from "@/components/landing/hero-rsc"; +import { getMarkdownOptions } from "@/lib/utils/markdown"; + +interface BlogEntryPageProps { + params: Promise<{ slug: string }>; +} + +export default async function BlogEntryPage({ params }: BlogEntryPageProps) { + const { slug } = await params; + + const entry = allBlogs.find((entry: Blog) => entry.slug === slug); + + if (!entry) { + notFound(); + } + + return ( + <> + +
+
+
+
+ + + Back to blog + +
+ +
+
+

{entry.title}

+
+ {entry.summary && ( +

{entry.summary}

+ )} + +
+
+ + {entry.image && ( +
+ {entry.image.alt +
+ )} + +
+ + {entry.content} + +
+
+
+
+
+
+ + ); +} + +export async function generateStaticParams() { + return allBlogs.map((entry: Blog) => ({ + slug: entry.slug, + })); +} + +export async function generateMetadata({ params }: BlogEntryPageProps) { + const { slug } = await params; + + const entry = allBlogs.find((entry: Blog) => entry.slug === slug); + + if (!entry) { + return {}; + } + + return { + title: `${entry.title} - Blog - LLM Gateway`, + description: entry.summary || "LLM Gateway blog post", + openGraph: { + title: `${entry.title} - Blog - LLM Gateway`, + description: entry.summary || "LLM Gateway blog post", + type: "article", + images: entry.image + ? [ + { + url: entry.image.src, + width: entry.image.width || 800, + height: entry.image.height || 400, + alt: entry.image.alt || entry.title, + }, + ] + : ["/opengraph.png"], + }, + twitter: { + card: "summary_large_image", + title: `${entry.title} - Blog - LLM Gateway`, + description: entry.summary || "LLM Gateway blog post", + }, + }; +} diff --git a/apps/ui/src/app/blog/category/[category]/page.tsx b/apps/ui/src/app/blog/category/[category]/page.tsx new file mode 100644 index 0000000000..e2ef7549b3 --- /dev/null +++ b/apps/ui/src/app/blog/category/[category]/page.tsx @@ -0,0 +1,68 @@ +import { BlogList } from "@/components/blog/list"; +import { HeroRSC } from "@/components/landing/hero-rsc"; + +interface BlogItem { + id: string; + slug: string; + date: string; + title: string; + summary: string; + categories?: string[]; +} + +function slugify(label: string) { + return label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +interface CategoryPageProps { + params: Promise<{ category: string }>; +} + +export default async function BlogCategoryPage({ params }: CategoryPageProps) { + const { category } = await params; + const slug = decodeURIComponent(category); + const { allBlogs } = (await import("content-collections")) as any; + + const filtered = (allBlogs as any[]) + .filter((entry: any) => !entry?.draft) + .filter((entry: any) => + (entry.categories || []).some((c: string) => slugify(c) === slug), + ) + .sort( + (a: any, b: any) => + new Date(b.date).getTime() - new Date(a.date).getTime(), + ) as BlogItem[]; + + return ( + <> + + + + ); +} + +export async function generateStaticParams() { + const { allBlogs } = (await import("content-collections")) as any; + const slugs = new Set(); + for (const post of allBlogs as any[]) { + (post.categories || []).forEach((c: string) => slugs.add(slugify(c))); + } + return Array.from(slugs).map((category) => ({ category })); +} + +export async function generateMetadata({ params }: CategoryPageProps) { + const { category } = await params; + const decoded = decodeURIComponent(category); + return { + title: `Blog: ${decoded} - LLM Gateway`, + description: `Articles in the ${decoded} category at LLM Gateway`, + }; +} diff --git a/apps/ui/src/app/blog/category/page.tsx b/apps/ui/src/app/blog/category/page.tsx new file mode 100644 index 0000000000..bdbbeff5d7 --- /dev/null +++ b/apps/ui/src/app/blog/category/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/apps/ui/src/app/blog/page.tsx b/apps/ui/src/app/blog/page.tsx new file mode 100644 index 0000000000..38f4de9319 --- /dev/null +++ b/apps/ui/src/app/blog/page.tsx @@ -0,0 +1,50 @@ +import { BlogList } from "@/components/blog/list"; +import { HeroRSC } from "@/components/landing/hero-rsc"; + +interface BlogItem { + id: string; + slug: string; + date: string; + title: string; + summary: string; +} + +export default async function BlogPage() { + const { allBlogs } = (await import("content-collections")) as any; + + const sortedEntries = (allBlogs as any[]) + .sort( + (a: any, b: any) => + new Date(b.date).getTime() - new Date(a.date).getTime(), + ) + .filter((entry: any) => !entry?.draft) + .map(({ ...entry }: any) => entry as BlogItem); + + return ( +
+ + +
+ ); +} + +export async function generateMetadata() { + return { + title: "Blog - LLM Gateway", + description: "News, tutorials, and deep-dives from the LLM Gateway team.", + openGraph: { + title: "Blog - LLM Gateway", + description: "News, tutorials, and deep-dives from the LLM Gateway team.", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Blog - LLM Gateway", + description: "News, tutorials, and deep-dives from the LLM Gateway team.", + }, + }; +} diff --git a/apps/ui/src/app/changelog/[slug]/page.tsx b/apps/ui/src/app/changelog/[slug]/page.tsx index f72b9e6770..78088cfa2b 100644 --- a/apps/ui/src/app/changelog/[slug]/page.tsx +++ b/apps/ui/src/app/changelog/[slug]/page.tsx @@ -5,8 +5,8 @@ import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { AppHeader } from "@/components/changelog/app-header"; import Footer from "@/components/landing/footer"; +import { HeroRSC } from "@/components/landing/hero-rsc"; import { getMarkdownOptions } from "@/lib/utils/markdown"; interface ChangelogEntryPageProps { @@ -25,59 +25,62 @@ export default async function ChangelogEntryPage({ } return ( -
- -
-
-
- - - Back to changelog - -
+ <> + -
-
-

{entry.title}

-
- {entry.summary && ( -

{entry.summary}

- )} - -
-
+
+
+
+
+ + + Back to changelog + +
- {entry.image && ( -
- {entry.image.alt -
- )} +
+
+

{entry.title}

+
+ {entry.summary && ( +

{entry.summary}

+ )} + +
+
-
- - {entry.content} - -
-
-
-
-
-
+ {entry.image && ( +
+ {entry.image.alt +
+ )} + +
+ + {entry.content} + +
+
+
+
+
+
+ ); } diff --git a/apps/ui/src/app/changelog/page.tsx b/apps/ui/src/app/changelog/page.tsx index e54f56ab27..c18d2dec7f 100644 --- a/apps/ui/src/app/changelog/page.tsx +++ b/apps/ui/src/app/changelog/page.tsx @@ -1,6 +1,7 @@ import { allChangelogs, type Changelog } from "content-collections"; import { ChangelogComponent } from "@/components/changelog"; +import { HeroRSC } from "@/components/landing/hero-rsc"; export default async function ChangelogPage() { const sortedEntries = allChangelogs @@ -13,6 +14,7 @@ export default async function ChangelogPage() { return (
+
); diff --git a/apps/ui/src/app/models/[name]/page.tsx b/apps/ui/src/app/models/[name]/page.tsx index 892b0659d1..c20cb00120 100644 --- a/apps/ui/src/app/models/[name]/page.tsx +++ b/apps/ui/src/app/models/[name]/page.tsx @@ -4,7 +4,16 @@ import { type StabilityLevel, type ModelDefinition, } from "@llmgateway/models"; -import { AlertTriangle } from "lucide-react"; +import { + AlertTriangle, + Play, + Zap, + Eye, + Wrench, + MessageSquare, + ImagePlus, +} from "lucide-react"; +import Link from "next/link"; import { notFound } from "next/navigation"; import Footer from "@/components/landing/footer"; @@ -12,6 +21,7 @@ import { Navbar } from "@/components/landing/navbar"; import { CopyModelName } from "@/components/models/copy-model-name"; import { ProviderCard } from "@/components/models/provider-card"; import { Badge } from "@/lib/components/badge"; +import { Button } from "@/lib/components/button"; interface PageProps { params: Promise<{ name: string }>; @@ -76,13 +86,13 @@ export default async function ModelPage({ params }: PageProps) {

- {modelDef.id} + {modelDef.name}

{shouldShowStabilityWarning(modelDef.stability) && ( )}
-
+
{(() => { const stabilityProps = getStabilityBadgeProps( @@ -101,6 +111,16 @@ export default async function ModelPage({ params }: PageProps) { ); })()} + + + +
@@ -138,43 +158,85 @@ export default async function ModelPage({ params }: PageProps) {
-

- {modelDef.id} is available across multiple providers with - different configurations, pricing, and performance - characteristics. Choose the provider that best fits your needs. -

- {shouldShowStabilityWarning(modelDef.stability) && ( -
-
- -
-

- {modelDef.stability === "experimental" - ? "Experimental" - : "Unstable"}{" "} - Model Warning -

-

- This model is marked as {modelDef.stability} and may have - issues with reliability, performance, or consistency. Use - with caution in production environments. -

+
+ {(() => { + const items: Array<{ + key: string; + icon: any; + label: string; + color: string; + }> = []; + const hasStreaming = modelProviders.some((p) => p.streaming); + const hasVision = modelProviders.some((p) => p.vision); + const hasTools = modelProviders.some((p) => p.tools); + const hasReasoning = modelProviders.some((p) => p.reasoning); + const hasImageGen = Array.isArray((modelDef as any)?.output) + ? ((modelDef as any).output as string[]).includes("image") + : false; + + if (hasStreaming) { + items.push({ + key: "streaming", + icon: Zap, + label: "Streaming", + color: "text-blue-500", + }); + } + if (hasVision) { + items.push({ + key: "vision", + icon: Eye, + label: "Vision", + color: "text-green-500", + }); + } + if (hasTools) { + items.push({ + key: "tools", + icon: Wrench, + label: "Tools", + color: "text-purple-500", + }); + } + if (hasReasoning) { + items.push({ + key: "reasoning", + icon: MessageSquare, + label: "Reasoning", + color: "text-orange-500", + }); + } + if (hasImageGen) { + items.push({ + key: "image", + icon: ImagePlus, + label: "Image Generation", + color: "text-pink-500", + }); + } + + return items.map(({ key, icon: Icon, label, color }) => ( +
+ + {label}
-
-
- )} + )); + })()} +

- Providers for {modelDef.id} + Providers for {modelDef.name}

LLM Gateway routes requests to the best providers that are - able to handle your prompt size and parameters, with fallbacks - to maximize uptime. + able to handle your prompt size and parameters.

diff --git a/apps/ui/src/components/blog/list.tsx b/apps/ui/src/components/blog/list.tsx new file mode 100644 index 0000000000..51e938fe6e --- /dev/null +++ b/apps/ui/src/components/blog/list.tsx @@ -0,0 +1,153 @@ +import Image from "next/image"; +import Link from "next/link"; + +import Footer from "@/components/landing/footer"; + +interface BlogItemImage { + src: string; + alt: string; + width: number; + height: number; +} + +interface BlogItem { + id: string; + slug: string; + date: string; + title: string; + summary: string; + categories?: string[]; + image?: BlogItemImage; +} + +function slugify(label: string) { + return label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +interface BlogListProps { + entries?: BlogItem[]; + selectedCategory?: string; // slug form + heading?: string; + subheading?: string; +} + +export function BlogList({ + entries, + selectedCategory, + heading = "Blog", + subheading = "Latest news and updates from LLM Gateway", +}: BlogListProps = {}) { + const blogEntries = entries || []; + const categoryList = ["Announcements", "Guides", "Engineering", "Changelog"]; + + return ( +
+
+
+

+ {heading} +

+

{subheading}

+
+ + {categoryList.length > 0 && ( + + )} + +
+ {blogEntries.map((entry: BlogItem) => ( +
+
+ + {entry?.image ? ( + {entry.image.alt} + ) : ( +
+ )} + +
+

+ + {entry.title} + +

+ {entry?.categories && entry.categories.length > 0 && ( +
+ {entry.categories.map((cat) => ( + + {cat} + + ))} +
+ )} +

+ {entry.summary} +

+
+ {new Date(entry.date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} +
+
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/changelog/changelog.tsx b/apps/ui/src/components/changelog/changelog.tsx index 783bb5f5d4..705c4ae337 100644 --- a/apps/ui/src/components/changelog/changelog.tsx +++ b/apps/ui/src/components/changelog/changelog.tsx @@ -1,7 +1,6 @@ import Image from "next/image"; import Link from "next/link"; -import { AppHeader } from "./app-header"; import Footer from "@/components/landing/footer"; import type { ChangelogFrontmatter } from "@/lib/utils/markdown"; @@ -14,20 +13,26 @@ export function ChangelogComponent({ entries }: ChangelogComponentProps = {}) { const changelogEntries = entries || []; return ( -
- +
-
-

- Changelog -

- {/* */} +
+

+ Stay up to date with the latest features, improvements, and fixes in + LLM Gateway. +

@@ -36,16 +41,18 @@ export function ChangelogComponent({ entries }: ChangelogComponentProps = {}) { key={entry.id} className="grid md:grid-cols-[150px_1fr] gap-x-8 gap-y-4" > - +
+ +

diff --git a/apps/ui/src/components/landing/navbar.tsx b/apps/ui/src/components/landing/navbar.tsx index 9bff5b2c4c..1acca3e69d 100644 --- a/apps/ui/src/components/landing/navbar.tsx +++ b/apps/ui/src/components/landing/navbar.tsx @@ -28,6 +28,7 @@ export const Navbar = ({ children }: { children?: React.ReactNode }) => { ]; const resourcesItems = [ + { name: "Blog", href: "/blog" }, { name: "Models", href: "/models" }, { name: "Playground", href: "/playground" }, { name: "Providers", href: "/providers" }, diff --git a/apps/ui/src/content/blog/2025-04-12-introducing-llm-gateway.md b/apps/ui/src/content/blog/2025-04-12-introducing-llm-gateway.md new file mode 100644 index 0000000000..da7893e1ec --- /dev/null +++ b/apps/ui/src/content/blog/2025-04-12-introducing-llm-gateway.md @@ -0,0 +1,46 @@ +--- +id: blog-introducing-llm-gateway +slug: introducing-llm-gateway +date: 2025-04-12 +title: Introducing LLM Gateway +summary: Meet the open-source API gateway for routing, observability, and cost tracking across LLM providers. +categories: ["Announcements"] +image: + src: "/blog/blog-introducing-llm-gateway.png" + alt: "LLM Gateway" + width: 2282 + height: 1198 +--- + +# LLM Gateway + +LLM Gateway is an open-source API gateway for Large Language Models (LLMs). It acts as middleware between your apps and LLM providers so you can: + +- **Route**: Switch between providers like OpenAI, Anthropic, and Google with a single API +- **Manage**: Centralize and rotate provider API keys +- **Observe**: Track token usage, latency, and error rates +- **Optimize**: Analyze cost and performance to pick the best models for your workload + +## Why LLM Gateway? + +Operating across multiple LLM providers quickly becomes complex credentials, SDK differences, changing models, and cost variance. LLM Gateway standardizes the interface and gives you the visibility to make data-driven choices. + +## One Compatible Endpoint + +LLM Gateway uses an OpenAI-compatible API format, so migrating is seamless: + +```bash +curl -X POST https://api.llmgateway.io/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $LLM_GATEWAY_API_KEY" \ + -d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello, how are you?"}] + }' +``` + +## Deep Observability + +Get **usage metrics**, **cost analysis**, and **performance tracking** broken down by model and provider, helping you reason about tradeoffs. You can compare latency, token usage, and error rates to choose the best fit per task. + +If you're new to LLM Gateway, read our [Docs](/docs) to get started. diff --git a/apps/ui/src/content/blog/2025-05-01-self-host-llm-gateway.md b/apps/ui/src/content/blog/2025-05-01-self-host-llm-gateway.md new file mode 100644 index 0000000000..7d1b14b48b --- /dev/null +++ b/apps/ui/src/content/blog/2025-05-01-self-host-llm-gateway.md @@ -0,0 +1,47 @@ +--- +id: blog-self-host-llm-gateway +slug: how-to-self-host-llm-gateway +date: 2025-05-01 +title: How to Self-Host LLM Gateway +summary: Deploy LLM Gateway locally or in the cloud using our unified Docker image or split services. +categories: ["Guides"] +image: + src: "/blog/how-to-self-host-llm-gateway.png" + alt: "LLM Gateway" + width: 2282 + height: 1198 +--- + +## Option 1: Unified Docker Image (Easiest) + +```bash +docker run -d \ + --name llmgateway \ + --restart unless-stopped \ + -p 3002:3002 -p 3005:3005 -p 4001:4001 -p 4002:4002 \ + -v ~/llmgateway_data:/var/lib/postgresql/data \ + -e OPENAI_API_KEY=sk-your_openai_key_here \ + -e AUTH_SECRET=your-secret-key-here \ + ghcr.io/theopenco/llmgateway-unified:latest +``` + +Prefer pinning the image to the latest release tag. You can also run it via Docker Compose. + +## Option 2: Split Services via Docker Compose + +```bash +git clone https://github.com/theopenco/llmgateway.git +cd llmgateway +cp .env.example .env +# edit .env +docker compose -f infra/docker-compose.split.yml up -d +``` + +### Access + +- Web: http://localhost:3002 +- Docs: http://localhost:3005 +- API: http://localhost:4002 +- Gateway: http://localhost:4001 + +See the full guide: [`Self Host`](https://raw.githubusercontent.com/theopenco/llmgateway/refs/heads/main/apps/docs/content/self-host.mdx). diff --git a/apps/ui/src/content/blog/2025-05-10-custom-providers.md b/apps/ui/src/content/blog/2025-05-10-custom-providers.md new file mode 100644 index 0000000000..8909a6bc44 --- /dev/null +++ b/apps/ui/src/content/blog/2025-05-10-custom-providers.md @@ -0,0 +1,31 @@ +--- +id: blog-custom-providers +slug: custom-openai-compatible-providers +date: 2025-05-10 +title: Custom OpenAI-Compatible Providers Are Now Supported +summary: Bring your own OpenAI-compatible endpoints and route them via LLM Gateway. +categories: ["Announcements"] +image: + src: "/blog/custom-openai-compatible-providers.png" + alt: "LLM Gateway" + width: 2282 + height: 1198 +--- + +You can now register custom OpenAI-compatible providers in LLM Gateway. Perfect for internal deployments or specialized third-party APIs that speak the OpenAI Chat Completions format. + +### Configure a Custom Provider + +Add a provider in the UI (lowercase name, base URL, and token). Then call models via `{providerName}/{modelName}`: + +```bash +curl -X POST "https://api.llmgateway.io/v1/chat/completions" \ + -H "Authorization: Bearer $LLM_GATEWAY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "mycompany/custom-gpt-4", + "messages": [{"role": "user", "content": "Hello from my custom provider!"}] + }' +``` + +Requirements include a lowercase provider name and a valid HTTPS base URL. See details in the docs: [Custom Providers](https://docs.llmgateway.io/features/custom-providers). diff --git a/apps/ui/src/types/content-collections.d.ts b/apps/ui/src/types/content-collections.d.ts index 48aea7f040..9aa23ad541 100644 --- a/apps/ui/src/types/content-collections.d.ts +++ b/apps/ui/src/types/content-collections.d.ts @@ -16,4 +16,21 @@ declare module "content-collections" { } export const allChangelogs: Changelog[]; + + export interface Blog { + id: string; + slug: string; + date: string; + title: string; + summary: string; + image: { + src: string; + alt: string; + width: number; + height: number; + }; + content: string; + } + + export const allBlogs: Blog[]; }