|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useState } from "react"; |
| 4 | +import { |
| 5 | + Card, |
| 6 | + CardContent, |
| 7 | + CardDescription, |
| 8 | + CardHeader, |
| 9 | + CardTitle, |
| 10 | +} from "@/components/ui/card"; |
| 11 | + |
| 12 | +interface ModelSpend { |
| 13 | + modelKey: string; |
| 14 | + displayName: string; |
| 15 | + inputTokens: number; |
| 16 | + outputTokens: number; |
| 17 | + totalTokens: number; |
| 18 | + listCostUsd: number; |
| 19 | + effectiveCostUsd: number; |
| 20 | +} |
| 21 | + |
| 22 | +interface SourceSpend { |
| 23 | + source: string; |
| 24 | + inputTokens: number; |
| 25 | + outputTokens: number; |
| 26 | + totalTokens: number; |
| 27 | + listCostUsd: number; |
| 28 | + effectiveCostUsd: number; |
| 29 | +} |
| 30 | + |
| 31 | +interface DaySpend { |
| 32 | + date: string; |
| 33 | + inputTokens: number; |
| 34 | + outputTokens: number; |
| 35 | + totalTokens: number; |
| 36 | + listCostUsd: number; |
| 37 | + effectiveCostUsd: number; |
| 38 | +} |
| 39 | + |
| 40 | +interface SpendSummary { |
| 41 | + totalInputTokens: number; |
| 42 | + totalOutputTokens: number; |
| 43 | + totalTokens: number; |
| 44 | + listCostUsd: number; |
| 45 | + effectiveCostUsd: number; |
| 46 | + discountMultiplier: number; |
| 47 | + formattedListCost: string; |
| 48 | + formattedEffectiveCost: string; |
| 49 | + bySource: Record<string, SourceSpend>; |
| 50 | + byModel: Record<string, ModelSpend>; |
| 51 | + byDay: DaySpend[]; |
| 52 | + gcpSpendUsd?: number; |
| 53 | + gcpSpendFormatted?: string; |
| 54 | + gcpSource?: 'bigquery' | 'env'; |
| 55 | + gcpTable?: string; |
| 56 | + gcpDaily?: { date: string; costUsd: number }[]; |
| 57 | +} |
| 58 | + |
| 59 | +function formatTokens(n: number): string { |
| 60 | + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; |
| 61 | + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; |
| 62 | + return n.toLocaleString(); |
| 63 | +} |
| 64 | + |
| 65 | +function formatUsd(n: number): string { |
| 66 | + return `$${n.toFixed(4)}`; |
| 67 | +} |
| 68 | + |
| 69 | +export default function EvalsPage() { |
| 70 | + const [spend, setSpend] = useState<SpendSummary | null>(null); |
| 71 | + const [loading, setLoading] = useState(true); |
| 72 | + const [error, setError] = useState<string | null>(null); |
| 73 | + |
| 74 | + useEffect(() => { |
| 75 | + fetch("/api/evals") |
| 76 | + .then((res) => { |
| 77 | + if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 78 | + return res.json(); |
| 79 | + }) |
| 80 | + .then((data: SpendSummary) => { |
| 81 | + setSpend(data); |
| 82 | + setLoading(false); |
| 83 | + }) |
| 84 | + .catch((err) => { |
| 85 | + setError(err instanceof Error ? err.message : String(err)); |
| 86 | + setLoading(false); |
| 87 | + }); |
| 88 | + }, []); |
| 89 | + |
| 90 | + if (loading) { |
| 91 | + return ( |
| 92 | + <div className="flex h-full items-center justify-center p-6"> |
| 93 | + Loading AI spend estimate… |
| 94 | + </div> |
| 95 | + ); |
| 96 | + } |
| 97 | + |
| 98 | + if (error || !spend) { |
| 99 | + return ( |
| 100 | + <div className="flex h-full items-center justify-center p-6 text-destructive"> |
| 101 | + Failed to load spend estimate: {error || "unknown error"} |
| 102 | + </div> |
| 103 | + ); |
| 104 | + } |
| 105 | + |
| 106 | + const bySourceList = Object.values(spend.bySource); |
| 107 | + const byModelList = Object.values(spend.byModel); |
| 108 | + const hasGcp = spend.gcpSpendUsd !== undefined; |
| 109 | + |
| 110 | + return ( |
| 111 | + <div className="flex flex-col gap-6 p-6"> |
| 112 | + <div> |
| 113 | + <h1 className="text-3xl font-bold">Evals</h1> |
| 114 | + <p className="text-muted-foreground"> |
| 115 | + AI spend estimate across conductor traces and retrieval audits |
| 116 | + </p> |
| 117 | + </div> |
| 118 | + |
| 119 | + {/* Summary Cards */} |
| 120 | + <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> |
| 121 | + <Card> |
| 122 | + <CardHeader className="pb-2"> |
| 123 | + <CardTitle className="text-sm font-medium">Effective Cost</CardTitle> |
| 124 | + </CardHeader> |
| 125 | + <CardContent> |
| 126 | + <div className="text-2xl font-bold text-green-600"> |
| 127 | + {spend.formattedEffectiveCost} |
| 128 | + </div> |
| 129 | + <p className="text-xs text-muted-foreground"> |
| 130 | + After {(spend.discountMultiplier * 100).toFixed(0)}% discount ramp |
| 131 | + </p> |
| 132 | + </CardContent> |
| 133 | + </Card> |
| 134 | + |
| 135 | + <Card> |
| 136 | + <CardHeader className="pb-2"> |
| 137 | + <CardTitle className="text-sm font-medium">List Cost</CardTitle> |
| 138 | + </CardHeader> |
| 139 | + <CardContent> |
| 140 | + <div className="text-2xl font-bold">{spend.formattedListCost}</div> |
| 141 | + <p className="text-xs text-muted-foreground">At posted model prices</p> |
| 142 | + </CardContent> |
| 143 | + </Card> |
| 144 | + |
| 145 | + <Card> |
| 146 | + <CardHeader className="pb-2"> |
| 147 | + <CardTitle className="text-sm font-medium">Total Tokens</CardTitle> |
| 148 | + </CardHeader> |
| 149 | + <CardContent> |
| 150 | + <div className="text-2xl font-bold text-blue-600"> |
| 151 | + {formatTokens(spend.totalTokens)} |
| 152 | + </div> |
| 153 | + <p className="text-xs text-muted-foreground"> |
| 154 | + {formatTokens(spend.totalInputTokens)} in /{" "} |
| 155 | + {formatTokens(spend.totalOutputTokens)} out |
| 156 | + </p> |
| 157 | + </CardContent> |
| 158 | + </Card> |
| 159 | + |
| 160 | + <Card> |
| 161 | + <CardHeader className="pb-2"> |
| 162 | + <CardTitle className="text-sm font-medium">GCP Spend</CardTitle> |
| 163 | + </CardHeader> |
| 164 | + <CardContent> |
| 165 | + <div className="text-2xl font-bold text-purple-600"> |
| 166 | + {spend.gcpSpendFormatted ?? "—"} |
| 167 | + </div> |
| 168 | + <p className="text-xs text-muted-foreground"> |
| 169 | + {spend.gcpSource === "bigquery" |
| 170 | + ? `Live BigQuery ${spend.gcpTable ? `· ${spend.gcpTable}` : ""}` |
| 171 | + : hasGcp |
| 172 | + ? "From GCP_AI_SPEND_USD" |
| 173 | + : "Set GCP billing env vars to include"} |
| 174 | + </p> |
| 175 | + </CardContent> |
| 176 | + </Card> |
| 177 | + </div> |
| 178 | + |
| 179 | + {/* By Source */} |
| 180 | + <Card> |
| 181 | + <CardHeader> |
| 182 | + <CardTitle>By Source</CardTitle> |
| 183 | + <CardDescription> |
| 184 | + Token usage and cost grouped by subsystem |
| 185 | + </CardDescription> |
| 186 | + </CardHeader> |
| 187 | + <CardContent> |
| 188 | + {bySourceList.length === 0 ? ( |
| 189 | + <p className="text-muted-foreground">No usage data found.</p> |
| 190 | + ) : ( |
| 191 | + <div className="overflow-x-auto"> |
| 192 | + <table className="w-full text-sm"> |
| 193 | + <thead> |
| 194 | + <tr className="border-b"> |
| 195 | + <th className="text-left py-2 font-medium">Source</th> |
| 196 | + <th className="text-right py-2 font-medium">Tokens</th> |
| 197 | + <th className="text-right py-2 font-medium">List Cost</th> |
| 198 | + <th className="text-right py-2 font-medium">Effective Cost</th> |
| 199 | + </tr> |
| 200 | + </thead> |
| 201 | + <tbody> |
| 202 | + {bySourceList.map((s) => ( |
| 203 | + <tr key={s.source} className="border-b last:border-0"> |
| 204 | + <td className="py-2 capitalize">{s.source}</td> |
| 205 | + <td className="text-right py-2"> |
| 206 | + {formatTokens(s.totalTokens)} |
| 207 | + </td> |
| 208 | + <td className="text-right py-2">{formatUsd(s.listCostUsd)}</td> |
| 209 | + <td className="text-right py-2"> |
| 210 | + {formatUsd(s.effectiveCostUsd)} |
| 211 | + </td> |
| 212 | + </tr> |
| 213 | + ))} |
| 214 | + </tbody> |
| 215 | + </table> |
| 216 | + </div> |
| 217 | + )} |
| 218 | + </CardContent> |
| 219 | + </Card> |
| 220 | + |
| 221 | + {/* By Model */} |
| 222 | + <Card> |
| 223 | + <CardHeader> |
| 224 | + <CardTitle>By Model</CardTitle> |
| 225 | + <CardDescription> |
| 226 | + Cost breakdown per provider/model pair |
| 227 | + </CardDescription> |
| 228 | + </CardHeader> |
| 229 | + <CardContent> |
| 230 | + {byModelList.length === 0 ? ( |
| 231 | + <p className="text-muted-foreground">No model usage data found.</p> |
| 232 | + ) : ( |
| 233 | + <div className="overflow-x-auto"> |
| 234 | + <table className="w-full text-sm"> |
| 235 | + <thead> |
| 236 | + <tr className="border-b"> |
| 237 | + <th className="text-left py-2 font-medium">Model</th> |
| 238 | + <th className="text-right py-2 font-medium">Tokens</th> |
| 239 | + <th className="text-right py-2 font-medium">List Cost</th> |
| 240 | + <th className="text-right py-2 font-medium">Effective Cost</th> |
| 241 | + </tr> |
| 242 | + </thead> |
| 243 | + <tbody> |
| 244 | + {byModelList.map((m) => ( |
| 245 | + <tr key={m.modelKey} className="border-b last:border-0"> |
| 246 | + <td className="py-2">{m.displayName}</td> |
| 247 | + <td className="text-right py-2"> |
| 248 | + {formatTokens(m.totalTokens)} |
| 249 | + </td> |
| 250 | + <td className="text-right py-2">{formatUsd(m.listCostUsd)}</td> |
| 251 | + <td className="text-right py-2"> |
| 252 | + {formatUsd(m.effectiveCostUsd)} |
| 253 | + </td> |
| 254 | + </tr> |
| 255 | + ))} |
| 256 | + </tbody> |
| 257 | + </table> |
| 258 | + </div> |
| 259 | + )} |
| 260 | + </CardContent> |
| 261 | + </Card> |
| 262 | + |
| 263 | + {/* By Day */} |
| 264 | + <Card> |
| 265 | + <CardHeader> |
| 266 | + <CardTitle>Daily Trend</CardTitle> |
| 267 | + <CardDescription>Spend and tokens per day</CardDescription> |
| 268 | + </CardHeader> |
| 269 | + <CardContent> |
| 270 | + {spend.byDay.length === 0 ? ( |
| 271 | + <p className="text-muted-foreground">No daily data found.</p> |
| 272 | + ) : ( |
| 273 | + <div className="overflow-x-auto"> |
| 274 | + <table className="w-full text-sm"> |
| 275 | + <thead> |
| 276 | + <tr className="border-b"> |
| 277 | + <th className="text-left py-2 font-medium">Date</th> |
| 278 | + <th className="text-right py-2 font-medium">Tokens</th> |
| 279 | + <th className="text-right py-2 font-medium">List Cost</th> |
| 280 | + <th className="text-right py-2 font-medium">Effective Cost</th> |
| 281 | + </tr> |
| 282 | + </thead> |
| 283 | + <tbody> |
| 284 | + {spend.byDay.map((d) => ( |
| 285 | + <tr key={d.date} className="border-b last:border-0"> |
| 286 | + <td className="py-2">{d.date}</td> |
| 287 | + <td className="text-right py-2"> |
| 288 | + {formatTokens(d.totalTokens)} |
| 289 | + </td> |
| 290 | + <td className="text-right py-2">{formatUsd(d.listCostUsd)}</td> |
| 291 | + <td className="text-right py-2"> |
| 292 | + {formatUsd(d.effectiveCostUsd)} |
| 293 | + </td> |
| 294 | + </tr> |
| 295 | + ))} |
| 296 | + </tbody> |
| 297 | + </table> |
| 298 | + </div> |
| 299 | + )} |
| 300 | + </CardContent> |
| 301 | + </Card> |
| 302 | + |
| 303 | + {/* GCP Daily Spend */} |
| 304 | + {spend.gcpSource === "bigquery" && ( |
| 305 | + <Card> |
| 306 | + <CardHeader> |
| 307 | + <CardTitle>GCP Daily Spend</CardTitle> |
| 308 | + <CardDescription> |
| 309 | + Live GCP cost from BigQuery{" "} |
| 310 | + {spend.gcpTable ? `· ${spend.gcpTable}` : ""} |
| 311 | + </CardDescription> |
| 312 | + </CardHeader> |
| 313 | + <CardContent> |
| 314 | + {!spend.gcpDaily || spend.gcpDaily.length === 0 ? ( |
| 315 | + <p className="text-muted-foreground">No GCP daily data found.</p> |
| 316 | + ) : ( |
| 317 | + <div className="overflow-x-auto"> |
| 318 | + <table className="w-full text-sm"> |
| 319 | + <thead> |
| 320 | + <tr className="border-b"> |
| 321 | + <th className="text-left py-2 font-medium">Date</th> |
| 322 | + <th className="text-right py-2 font-medium">GCP Cost</th> |
| 323 | + </tr> |
| 324 | + </thead> |
| 325 | + <tbody> |
| 326 | + {spend.gcpDaily.map((d) => ( |
| 327 | + <tr key={d.date} className="border-b last:border-0"> |
| 328 | + <td className="py-2">{d.date}</td> |
| 329 | + <td className="text-right py-2">{formatUsd(d.costUsd)}</td> |
| 330 | + </tr> |
| 331 | + ))} |
| 332 | + </tbody> |
| 333 | + </table> |
| 334 | + </div> |
| 335 | + )} |
| 336 | + </CardContent> |
| 337 | + </Card> |
| 338 | + )} |
| 339 | + </div> |
| 340 | + ); |
| 341 | +} |
0 commit comments