Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/update-stats.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
cache: npm
cache-dependency-path: stats-cards/package-lock.json

- name: Install gifsicle
run: sudo apt-get update && sudo apt-get install -y gifsicle

- name: Install dependencies
working-directory: stats-cards
run: npm ci
Expand Down
9 changes: 5 additions & 4 deletions profile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ Find the best models & prices for your prompts — through a single, OpenAI-comp

<br>

<a href="https://openrouter.ai/models"><picture><source media="(prefers-color-scheme: dark)" srcset="./assets/stat-models-dark.gif"><img src="./assets/stat-models-light.gif" alt="Models on OpenRouter" width="272"></picture></a>
<a href="https://openrouter.ai/models"><picture><source media="(prefers-color-scheme: dark)" srcset="./assets/stat-providers-dark.gif"><img src="./assets/stat-providers-light.gif" alt="Providers on OpenRouter" width="272"></picture></a>
<a href="https://openrouter.ai/rankings"><picture><source media="(prefers-color-scheme: dark)" srcset="./assets/stat-tokens-dark.gif"><img src="./assets/stat-tokens-light.gif" alt="Tokens routed per month" width="272"></picture></a>
<a href="https://openrouter.ai/models"><picture><source media="(prefers-color-scheme: dark)" srcset="./assets/stat-models-dark.gif"><img src="./assets/stat-models-light.gif" alt="Models on OpenRouter" width="208"></picture></a>
<a href="https://openrouter.ai/models"><picture><source media="(prefers-color-scheme: dark)" srcset="./assets/stat-providers-dark.gif"><img src="./assets/stat-providers-light.gif" alt="Providers on OpenRouter" width="208"></picture></a>
<a href="https://openrouter.ai/rankings"><picture><source media="(prefers-color-scheme: dark)" srcset="./assets/stat-tokens-dark.gif"><img src="./assets/stat-tokens-light.gif" alt="Tokens routed per month" width="208"></picture></a>
<a href="https://openrouter.ai"><picture><source media="(prefers-color-scheme: dark)" srcset="./assets/stat-users-dark.gif"><img src="./assets/stat-users-light.gif" alt="Global users on OpenRouter" width="208"></picture></a>

[![Website](https://img.shields.io/badge/Website-openrouter.ai-1e293b?style=for-the-badge)](https://openrouter.ai)
[![Docs](https://img.shields.io/badge/Docs-openrouter.ai%2Fdocs-1e293b?style=for-the-badge)](https://openrouter.ai/docs)
Expand All @@ -29,7 +30,7 @@ Find the best models & prices for your prompts — through a single, OpenAI-comp

## Why OpenRouter?

- **One API, 340+ models.** Route requests to models from OpenAI, Anthropic, Google, Meta, Mistral, and many more — without changing your code.
- **One API, 400+ models.** Route requests to models from OpenAI, Anthropic, Google, Meta, Mistral, and many more — without changing your code.
- **Better prices & uptime.** Automatic fallbacks and price/performance routing across 90+ providers.
- **OpenAI-compatible.** Drop-in compatible with the OpenAI SDK — just change the base URL and key.
- **No lock-in.** Compare models, switch instantly, and pay as you go.
Expand Down
Binary file modified profile/assets/stat-models-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified profile/assets/stat-models-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified profile/assets/stat-providers-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified profile/assets/stat-providers-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified profile/assets/stat-tokens-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified profile/assets/stat-tokens-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added profile/assets/stat-users-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added profile/assets/stat-users-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 35 additions & 5 deletions stats-cards/scripts/fetch-stats.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,47 @@ const get = async (url) => {
return res.json();
};

const [models, providers, rankings] = await Promise.all([
get('https://openrouter.ai/api/v1/models'),
const getText = async (url) => {
const res = await fetch(url, {headers: {'user-agent': 'Mozilla/5.0'}});
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return res.text();
};

const [find, providers, rankings, homepage] = await Promise.all([
// Full marketplace listing (all modalities), unlike /api/v1/models which
// only covers chat-completions models.
get('https://openrouter.ai/api/frontend/v1/models/find'),
get('https://openrouter.ai/api/v1/providers'),
// Monthly window to match the homepage's canonical "Monthly Tokens" stat.
get('https://openrouter.ai/api/frontend/v1/rankings/models?view=month').catch(
() => null // undocumented endpoint — tolerate failure
),
// No public API exposes the user count; the homepage stat card is the
// canonical source ("10M+ Global Users"). Tolerate failure.
getText('https://openrouter.ai/').catch(() => null),
]);

const modelCount = models.data.length;
// Count only active root models: an active model has a non-null endpoint,
// and :variant slugs (e.g. ":free", ":extended") are excluded so each model
// is counted once.
const modelCount = new Set(
find.data.models
.filter((m) => m.endpoint != null && !m.slug.includes(':'))
.map((m) => m.slug)
).size;
const providerCount = providers.data.length;

// Round down to a "nice" number: nearest 100 at/above 100, nearest 10 below.
// Avoids oddly specific live counts on the homepage (e.g. 411 -> 400).
const nice = (n) => (n >= 100 ? Math.floor(n / 100) * 100 : Math.floor(n / 10) * 10);

// Parse "<value>M+ ... Global Users" from the homepage stats section.
let usersM = 10; // fallback matching the current homepage
if (homepage) {
const m = homepage.match(/(\d+(?:\.\d+)?)M\+<\/p>[^<]*<p[^>]*>Global Users/);
if (m) usersM = Number(m[1]);
}

let tokensT = 100; // conservative fallback
if (rankings?.data?.length) {
// The response contains multiple date buckets; only the latest one is
Expand All @@ -43,9 +72,10 @@ if (rankings?.data?.length) {
await mkdir('out', {recursive: true});

const props = {
models: {value: modelCount, suffix: '+', label: 'Models'},
providers: {value: providerCount, suffix: '+', label: 'Providers'},
models: {value: nice(modelCount), suffix: '+', label: 'Models'},
providers: {value: nice(providerCount), suffix: '+', label: 'Providers'},
tokens: {value: tokensT, suffix: 'T', label: 'Tokens / month'},
users: {value: usersM, suffix: 'M+', label: 'Global users'},
};

for (const [id, p] of Object.entries(props)) {
Expand Down
31 changes: 30 additions & 1 deletion stats-cards/scripts/render-all.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
// Renders all stat cards (dark + light) as transparent, play-once GIFs.
import {execSync} from 'node:child_process';

const stats = ['models', 'providers', 'tokens'];
const hasGifsicle = (() => {
try {
execSync('gifsicle --version', {stdio: 'ignore'});
return true;
} catch {
return false;
}
})();
if (!hasGifsicle) {
throw new Error(
'gifsicle is required (2x renders exceed the ~10MB GitHub camo limit without it). brew/apt install gifsicle.'
);
}

const stats = ['models', 'providers', 'tokens', 'users'];
const themes = ['dark', 'light'];

for (const stat of stats) {
Expand All @@ -13,11 +27,26 @@ for (const stat of stats) {
id,
out,
'--codec=gif',
// Supersample 2x (1760x712): GIF alpha is 1-bit, so the anti-aliased
// corner pixels that pass the 50% threshold form a light 1px ring.
// At the README's 202px display width, doubling the source resolution
// halves that ring's contribution to each screen pixel.
'--scale=2',
'--number-of-gif-loops=0',
'--image-format=png',
`--props=out/props-${stat}.json`,
].join(' ');
console.log(`\n▶ ${id}`);
execSync(cmd, {stdio: 'inherit'});
// High-quality Lanczos downscale to exactly 2x the README's 208px
// display width. Browsers downscale with cheap bilinear filtering
// (~4 samples per output pixel), so shipping the raw 1760px render
// aliased the text edges at 8.5x; a clean 2:1 step for the browser
// keeps glyph edges smooth. Also recompresses ~15MB -> well under
// GitHub's ~10MB camo proxy limit.
execSync(
`gifsicle -O3 --lossy=60 --resize-width 416 --resize-method lanczos3 --resize-colors 255 ${out} -o ${out}`,
{stdio: 'inherit'}
);
}
}
10 changes: 9 additions & 1 deletion stats-cards/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,19 @@ const STATS = [
label: 'Tokens / month',
sub: 'routed across the network',
},
{
id: 'users',
value: 10,
suffix: 'M+',
label: 'Global users',
sub: 'developers building on OpenRouter',
},
] as const;

export const Root: React.FC = () => {
return (
<>
{STATS.flatMap((stat) =>
{STATS.flatMap((stat, index) =>
(['dark', 'light'] as const).map((theme) => (
<Composition
key={`${stat.id}-${theme}`}
Expand All @@ -56,6 +63,7 @@ export const Root: React.FC = () => {
accent: ACCENT,
accent2: ACCENT2,
theme,
variant: index,
}}
/>
))
Expand Down
94 changes: 77 additions & 17 deletions stats-cards/src/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,33 @@ export const statCardSchema = z.object({
accent: z.string(),
accent2: z.string(),
theme: z.enum(['dark', 'light']),
// Position of this card in the row (0..n-1). Used to vary the background
// gradient/glow and phase-shift the loop so the four cards flow into each
// other side-by-side (and stacked) instead of looking identical.
variant: z.number().default(0),
});

type Props = z.infer<typeof statCardSchema>;

const THEMES = {
dark: {
surface: 'linear-gradient(145deg, #101114 0%, #0A0B0D 55%, #0E0F13 100%)',
// Flat base: the animated gradient wave exits offscreen before the
// play-once GIF holds its final frame, ending on this solid color.
base: '#0C0D10',
wave: 'rgba(99, 102, 241, 0.10)',
border: '#3B3D42',
text: '#DDE0E2',
sub: '#8B8D98',
dot: '#ffffff0c',
shineBase: '#DDE0E2',
},
light: {
surface: 'linear-gradient(145deg, #FFFFFF 0%, #FAFAFB 55%, #F4F4F6 100%)',
border: '#E4E4E7',
base: '#FAFAFB',
wave: 'rgba(99, 102, 241, 0.08)',
// Deliberately darker than the site's zinc-200: the GIF corner
// stair-step pixels take this color, and near-white reads as white
// fringing on dark page backgrounds.
border: '#B8B8C0',
text: '#09090B',
sub: '#52525B',
dot: '#09090B14',
Expand Down Expand Up @@ -64,11 +75,21 @@ export const StatCard: React.FC<Props> = ({
accent,
accent2,
theme,
variant = 0,
}) => {
const frame = useCurrentFrame();
const {fps, durationInFrames, width, height} = useVideoConfig();
const t = THEMES[theme];

// 0..1 across the row of four cards; drives the background variation.
const flow = variant / 3;
// Gradient tilts progressively left-to-right so adjacent edges continue
// into each other; also reads as a diagonal cascade when cards wrap 2x2.
const surfaceAngle = 115 + flow * 60; // 115 / 135 / 155 / 175
// Phase-shift the looping effects so the row animates as a wave.
const phase = (variant * durationInFrames) / 4;
const loopFrame = (frame + phase) % durationInFrames;

// --- Entrance ---
const enter = spring({frame, fps, config: {damping: 16, stiffness: 120}});
const rise = interpolate(enter, [0, 1], [24, 0]);
Expand All @@ -83,16 +104,41 @@ export const StatCard: React.FC<Props> = ({
const displayed = formatValue(countSpring * value, value);

// Theme loop easing: cubic-bezier(0.4, 0, 0.6, 1)
const loop = pingPong(frame, durationInFrames);
const loop = pingPong(loopFrame, durationInFrames);
const glow = interpolate(loop, [0, 1], [0.35, 0.75], {
easing: Easing.bezier(0.4, 0, 0.6, 1),
});

// Conic border sweep (full rotation per loop = seamless)
const sweep = (frame / durationInFrames) * 360;
const sweep = (loopFrame / durationInFrames) * 360;

// Beam of light traveling across the top edge
const beamX = interpolate(frame % durationInFrames, [0, durationInFrames], [-30, 130]);
// Beam of light traveling across the top edge. Driven by `frame` (not the
// phase-shifted loop) so it has fully exited when the GIF holds its last
// frame; the per-variant delay keeps the row animating as a wave.
const beamX = interpolate(
frame,
[variant * 2, durationInFrames * 0.75 + variant * 2],
[-30, 130],
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}
);

// Gradient wave traveling across the surface: starts fully offscreen left,
// exits fully offscreen right, so the held final frame is the flat base.
// Staggered per variant so it flows through the row of cards.
const waveX = interpolate(
frame,
[variant * 3, durationInFrames * 0.78 + variant * 3],
[-140, 110],
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}
);

// Ambient glows fade out completely before the final held frame.
const exit = interpolate(
frame,
[durationInFrames * 0.65, durationInFrames * 0.9],
[0, 1],
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}
);

// Shine sweeping across the number. Completes early and clamps at a
// position where the accent stripe (and its repeat tile) sit fully
Expand All @@ -108,31 +154,46 @@ export const StatCard: React.FC<Props> = ({
// Transparent canvas: GIF alpha is 1-bit, so no exterior glows/shadows.
<AbsoluteFill className="items-center justify-center" style={{fontFamily}}>
{/* Card with animated gradient border */}
{/* Large radius: GIF alpha is 1-bit, so anti-aliased corner pixels get
thresholded to opaque. With a big radius the stair-step follows the
curve; with a small one it filled the corners in as solid wedges. */}
<div
className="relative rounded-lg"
className="relative rounded-[28px]"
style={{
width: width - 8,
height: height - 8,
padding: 1.5,
padding: 2.5,
transform: `translateY(${rise}px) scale(${0.96 + enter * 0.04})`,
opacity: enter,
background: `conic-gradient(from ${sweep}deg at 50% 50%, ${accent}B0 0deg, transparent 70deg, transparent 180deg, ${accent2}70 250deg, transparent 310deg, ${accent}B0 360deg), linear-gradient(${t.border}, ${t.border})`,
}}
>
<div
className="relative h-full w-full overflow-hidden rounded-lg px-12"
style={{background: t.surface}}
className="relative h-full w-full overflow-hidden rounded-[26.5px] px-12"
style={{background: t.base}}
>
{/* Gradient wave: sweeps through once, then fully offscreen so the
final frame ends on the flat base color */}
<div
className="absolute top-0 h-full"
style={{
width: '130%',
left: `${waveX}%`,
background: `linear-gradient(${surfaceAngle - 25}deg, transparent 10%, ${t.wave} 45%, ${t.wave} 55%, transparent 90%)`,
}}
/>
{/* Ambient glow, clipped inside the card */}
<div
className="absolute rounded-full"
style={{
width: width * 0.8,
height: height * 1.2,
left: -width * 0.2,
// Glow center drifts across the row (left on card 0, right on
// card 3) so the highlight appears to travel through the set.
left: -width * 0.35 + flow * width * 0.7,
top: -height * 0.45,
background: `radial-gradient(circle, ${accent}${theme === 'dark' ? '18' : '10'} 0%, transparent 70%)`,
opacity: glow,
opacity: glow * (1 - exit),
filter: 'blur(20px)',
}}
/>
Expand All @@ -141,10 +202,10 @@ export const StatCard: React.FC<Props> = ({
style={{
width: width * 0.8,
height: height * 1.2,
right: -width * 0.2,
right: -width * 0.35 + (1 - flow) * width * 0.7,
bottom: -height * 0.45,
background: `radial-gradient(circle, ${accent2}${theme === 'dark' ? '14' : '0c'} 0%, transparent 70%)`,
opacity: 1 - glow * 0.6,
opacity: (1 - glow * 0.6) * (1 - exit),
filter: 'blur(20px)',
}}
/>
Expand All @@ -155,8 +216,7 @@ export const StatCard: React.FC<Props> = ({
style={{
backgroundImage: `radial-gradient(circle, ${t.dot} 1px, transparent 1px)`,
backgroundSize: '22px 22px',
maskImage:
'radial-gradient(ellipse at 30% 40%, black 20%, transparent 75%)',
maskImage: `radial-gradient(ellipse at ${20 + flow * 40}% 40%, black 20%, transparent 75%)`,
}}
/>

Expand Down