diff --git a/.github/workflows/update-stats.yml b/.github/workflows/update-stats.yml index e6663a1..5869022 100644 --- a/.github/workflows/update-stats.yml +++ b/.github/workflows/update-stats.yml @@ -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 diff --git a/profile/README.md b/profile/README.md index 517cbed..620dc60 100644 --- a/profile/README.md +++ b/profile/README.md @@ -14,9 +14,10 @@ Find the best models & prices for your prompts — through a single, OpenAI-comp
-Models on OpenRouter -Providers on OpenRouter -Tokens routed per month +Models on OpenRouter +Providers on OpenRouter +Tokens routed per month +Global users on OpenRouter [![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) @@ -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. diff --git a/profile/assets/stat-models-dark.gif b/profile/assets/stat-models-dark.gif index f43f210..ced3726 100644 Binary files a/profile/assets/stat-models-dark.gif and b/profile/assets/stat-models-dark.gif differ diff --git a/profile/assets/stat-models-light.gif b/profile/assets/stat-models-light.gif index bb4f742..f13d3ec 100644 Binary files a/profile/assets/stat-models-light.gif and b/profile/assets/stat-models-light.gif differ diff --git a/profile/assets/stat-providers-dark.gif b/profile/assets/stat-providers-dark.gif index df381c6..2986e11 100644 Binary files a/profile/assets/stat-providers-dark.gif and b/profile/assets/stat-providers-dark.gif differ diff --git a/profile/assets/stat-providers-light.gif b/profile/assets/stat-providers-light.gif index 64d9f9d..c92fd27 100644 Binary files a/profile/assets/stat-providers-light.gif and b/profile/assets/stat-providers-light.gif differ diff --git a/profile/assets/stat-tokens-dark.gif b/profile/assets/stat-tokens-dark.gif index 8cd04f2..9aa72f4 100644 Binary files a/profile/assets/stat-tokens-dark.gif and b/profile/assets/stat-tokens-dark.gif differ diff --git a/profile/assets/stat-tokens-light.gif b/profile/assets/stat-tokens-light.gif index 9a13f68..001327f 100644 Binary files a/profile/assets/stat-tokens-light.gif and b/profile/assets/stat-tokens-light.gif differ diff --git a/profile/assets/stat-users-dark.gif b/profile/assets/stat-users-dark.gif new file mode 100644 index 0000000..4db2737 Binary files /dev/null and b/profile/assets/stat-users-dark.gif differ diff --git a/profile/assets/stat-users-light.gif b/profile/assets/stat-users-light.gif new file mode 100644 index 0000000..da21fd3 Binary files /dev/null and b/profile/assets/stat-users-light.gif differ diff --git a/stats-cards/scripts/fetch-stats.mjs b/stats-cards/scripts/fetch-stats.mjs index 6d9c2b9..3909467 100644 --- a/stats-cards/scripts/fetch-stats.mjs +++ b/stats-cards/scripts/fetch-stats.mjs @@ -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 "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>[^<]*]*>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 @@ -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)) { diff --git a/stats-cards/scripts/render-all.mjs b/stats-cards/scripts/render-all.mjs index de5aa32..c3bb4fa 100644 --- a/stats-cards/scripts/render-all.mjs +++ b/stats-cards/scripts/render-all.mjs @@ -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) { @@ -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'} + ); } } diff --git a/stats-cards/src/Root.tsx b/stats-cards/src/Root.tsx index 0307443..0ed3f76 100644 --- a/stats-cards/src/Root.tsx +++ b/stats-cards/src/Root.tsx @@ -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) => ( { accent: ACCENT, accent2: ACCENT2, theme, + variant: index, }} /> )) diff --git a/stats-cards/src/StatCard.tsx b/stats-cards/src/StatCard.tsx index 074954a..7c618ed 100644 --- a/stats-cards/src/StatCard.tsx +++ b/stats-cards/src/StatCard.tsx @@ -19,13 +19,20 @@ 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; 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', @@ -33,8 +40,12 @@ const THEMES = { 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', @@ -64,11 +75,21 @@ export const StatCard: React.FC = ({ 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]); @@ -83,16 +104,41 @@ export const StatCard: React.FC = ({ 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 @@ -108,31 +154,46 @@ export const StatCard: React.FC = ({ // Transparent canvas: GIF alpha is 1-bit, so no exterior glows/shadows. {/* 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. */}
+ {/* Gradient wave: sweeps through once, then fully offscreen so the + final frame ends on the flat base color */} +
{/* Ambient glow, clipped inside the card */}
@@ -141,10 +202,10 @@ export const StatCard: React.FC = ({ 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)', }} /> @@ -155,8 +216,7 @@ export const StatCard: React.FC = ({ 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%)`, }} />