"use client"; import { useState, useEffect } from "react"; import React from "react"; // ──────────────────────────────────────────────────────────────────────────── // Types // ──────────────────────────────────────────────────────────────────────────── export interface FreeBudgetPerModel { provider: string; modelId: string; displayName: string; monthlyTokens: number; creditTokens: number; freeType: string; poolKey: string | null; tos: string; } export interface FreeBudgetData { steadyRecurringTokens: number; steadyWithRecurringCreditsTokens: number; firstMonthRealisticTokens: number; usedThisMonth: number; remaining: number; modelCount: number; poolCount: number; perModel: FreeBudgetPerModel[]; /** Extra recurring tokens/mo unlocked by a one-time small deposit (OpenRouter $10 → 1000 RPD). */ boostMonthlyTokens?: number; /** Providers that are permanently free but publish no token cap (rate/concurrency-limited). */ uncappedProviders?: string[]; headline?: string; } export type FreeBudgetSort = "tokens" | "name" | "provider"; // ──────────────────────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────────────────────── function fmt(n: number): string { if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B"; if (n >= 1e6) return Math.round(n / 1e6) + "M"; if (n >= 1e3) return Math.round(n / 1e3) + "K"; return String(n); } const FREE_TYPE_LABEL: Record = { "recurring-daily": "daily", "recurring-monthly": "monthly", "recurring-credit": "credit/mo", "recurring-uncapped": "uncapped", "one-time-initial": "signup credit", keyless: "keyless", discontinued: "discontinued", }; // Distinct hues for stacked bar segments (cycling) const BAR_HUES = [ "#6366f1", "#10b981", "#f59e0b", "#3b82f6", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4", "#84cc16", ]; const RECURRING_TYPES = new Set(["recurring-daily", "recurring-monthly", "keyless"]); interface BarSegment { key: string; label: string; tokens: number; color: string; } /** * Build an ordered list of bar segments from per-model data. * Recurring models sharing a poolKey collapse to ONE segment (pool MAX); poolKey===null * models each get a segment. Segments therefore sum to `steadyRecurringTokens`. */ function buildBarSegments(perModel: FreeBudgetPerModel[]): BarSegment[] { const providerColorCache = new Map(); function colorFor(provider: string): string { if (!providerColorCache.has(provider)) { providerColorCache.set(provider, BAR_HUES[providerColorCache.size % BAR_HUES.length]); } return providerColorCache.get(provider)!; } const seenPools = new Map(); const looseSegments: BarSegment[] = []; for (const m of perModel) { if (!RECURRING_TYPES.has(m.freeType)) continue; if (m.monthlyTokens <= 0) continue; if (m.poolKey) { const existing = seenPools.get(m.poolKey); if (!existing) { seenPools.set(m.poolKey, { key: `pool:${m.poolKey}`, label: `${m.displayName} (${m.provider})`, tokens: m.monthlyTokens, color: colorFor(m.provider), }); } else if (m.monthlyTokens > existing.tokens) { seenPools.set(m.poolKey, { ...existing, tokens: m.monthlyTokens, label: `${m.displayName} (${m.provider})` }); } } else { looseSegments.push({ key: `model:${m.modelId}`, label: `${m.displayName}`, tokens: m.monthlyTokens, color: colorFor(m.provider), }); } } return [...seenPools.values(), ...looseSegments]; } function colorForProvider(perModel: FreeBudgetPerModel[]): Map { const cache = new Map(); for (const m of perModel) { if (!cache.has(m.provider)) cache.set(m.provider, BAR_HUES[cache.size % BAR_HUES.length]); } return cache; } function sortRows(rows: FreeBudgetPerModel[], sort: FreeBudgetSort): FreeBudgetPerModel[] { const copy = rows.slice(); if (sort === "name") return copy.sort((a, b) => a.displayName.localeCompare(b.displayName)); if (sort === "provider") return copy.sort((a, b) => a.provider.localeCompare(b.provider) || b.monthlyTokens - a.monthlyTokens); return copy.sort((a, b) => b.monthlyTokens - a.monthlyTokens || b.creditTokens - a.creditTokens); } function tosBadge(tos: string): { icon: string; cls: string; title: string } | null { if (tos === "avoid") return { icon: "warning", cls: "text-amber-400", title: "ToS-restricted — review terms" }; if (tos === "caution") return { icon: "bolt", cls: "text-text-muted", title: "Caution — personal-use / proxy clauses" }; if (tos === "ok") return { icon: "check_circle", cls: "text-emerald-500", title: "Generally permissive" }; return null; } // ──────────────────────────────────────────────────────────────────────────── // KPI tile // ──────────────────────────────────────────────────────────────────────────── function Kpi({ label, value, valueClass }: { label: string; value: string; valueClass?: string }) { return (
{label} {value}
); } // ──────────────────────────────────────────────────────────────────────────── // Pure view (SSR-testable — no hooks). Sort/filter are controlled via props. // ──────────────────────────────────────────────────────────────────────────── export function FreeBudgetView({ data, sort = "tokens", hideAvoid = false, }: { data: FreeBudgetData; sort?: FreeBudgetSort; hideAvoid?: boolean; }) { const { steadyRecurringTokens, firstMonthRealisticTokens, usedThisMonth, remaining, perModel, boostMonthlyTokens = 0, uncappedProviders = [], } = data; const pct = steadyRecurringTokens > 0 ? Math.round((remaining / steadyRecurringTokens) * 100) : 0; const avoidModels = perModel.filter((m) => m.tos === "avoid"); const barSegments = buildBarSegments(perModel); const totalBarTokens = barSegments.reduce((s, seg) => s + seg.tokens, 0); const providerColor = colorForProvider(perModel); // Table rows: only entries with real budget; optional hide-ToS-avoid; sorted. let rows = perModel.filter((m) => m.monthlyTokens > 0 || m.creditTokens > 0); if (hideAvoid) rows = rows.filter((m) => m.tos !== "avoid"); rows = sortRows(rows, sort); return (
{/* Header */}
savings Free-token budget {fmt(remaining)} remaining · {pct}% of {fmt(steadyRecurringTokens)}
{/* KPI tiles */}
{/* Stacked bar — pool-deduped; segments sum to steadyRecurringTokens */} {barSegments.length > 0 && (
{barSegments.map((seg) => { const width = totalBarTokens > 0 ? ((seg.tokens / totalBarTokens) * 100).toFixed(2) : "0"; return (
); })}

Each segment = one free pool · pool-deduped, honest counting (no inflated rate-limit ceilings).

)} {/* Boost + uncapped callouts */} {boostMonthlyTokens > 0 && (
bolt Unlock ~{fmt(boostMonthlyTokens)} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)
)} {uncappedProviders.length > 0 && (
Permanently free, no published cap (rate-limited) — real access, not counted in the headline:
{uncappedProviders.map((p) => ( {p} ))}
)} {/* ToS-restricted callout */} {avoidModels.length > 0 && (
warning {avoidModels.length} model{avoidModels.length !== 1 ? "s" : ""} flagged as ToS-restricted — you decide
)} {/* Per-model table */}
{rows.map((m) => { const badge = tosBadge(m.tos); const amount = m.monthlyTokens > 0 ? fmt(m.monthlyTokens) : m.creditTokens > 0 ? `${fmt(m.creditTokens)} credit` : "—"; return ( ); })}
Provider Model Type Tokens/mo ToS
{m.provider} {m.displayName} {FREE_TYPE_LABEL[m.freeType] ?? m.freeType} {amount} {badge && ( {badge.icon} )}
); } // ──────────────────────────────────────────────────────────────────────────── // Fetch + interactivity wrapper (client component) // ──────────────────────────────────────────────────────────────────────────── export default function FreeBudgetCard() { const [data, setData] = useState(null); const [sort, setSort] = useState("tokens"); const [hideAvoid, setHideAvoid] = useState(false); useEffect(() => { fetch("/api/free-tier/summary") .then((r) => (r.ok ? r.json() : null)) .then((json) => { if (json) setData(json as FreeBudgetData); }) .catch(() => { /* best-effort */ }); }, []); if (!data) return null; return (
{/* Controls */}
Sort
); }