1use std::fmt::Write as _;
11
12use super::admin_api::{UsageBreakdownResponse, usage_breakdown};
13use super::admin_timeseries::{TimeseriesResponse, timeseries};
14
15#[derive(Debug, Clone)]
17pub struct ReportMeta {
18 pub org_label: Option<String>,
19 pub seats: Option<u32>,
20 pub reference_model: Option<String>,
21}
22
23pub async fn generate(
29 pool: &deadpool_postgres::Pool,
30 from: chrono::DateTime<chrono::Utc>,
31 to: chrono::DateTime<chrono::Utc>,
32 meta: &ReportMeta,
33) -> anyhow::Result<String> {
34 let usage = usage_breakdown(pool, from, to, meta.seats).await?;
35 let series = timeseries(pool, from, to).await?;
36 let routed = routed_requests(pool, from, to).await?;
37 Ok(render(&usage, &series, routed, meta))
38}
39
40async fn routed_requests(
42 pool: &deadpool_postgres::Pool,
43 from: chrono::DateTime<chrono::Utc>,
44 to: chrono::DateTime<chrono::Utc>,
45) -> anyhow::Result<i64> {
46 let client = pool.get().await?;
47 let row = client
48 .query_one(
49 "SELECT count(*) AS n FROM usage_events \
50 WHERE ts >= $1 AND ts <= $2 AND routed_from IS NOT NULL",
51 &[&from, &to],
52 )
53 .await?;
54 Ok(row.get("n"))
55}
56
57fn usd(v: f64) -> String {
58 if v.abs() >= 1_000_000.0 {
59 format!("${:.2}M", v / 1_000_000.0)
60 } else if v.abs() >= 10_000.0 {
61 format!("${:.1}k", v / 1_000.0)
62 } else if v.abs() >= 100.0 {
63 format!("${v:.0}")
64 } else {
65 format!("${v:.2}")
66 }
67}
68
69fn n(v: i64) -> String {
70 if v >= 1_000_000 {
71 format!("{:.1}M", v as f64 / 1e6)
72 } else if v >= 10_000 {
73 format!("{:.1}k", v as f64 / 1e3)
74 } else {
75 v.to_string()
76 }
77}
78
79fn esc(s: &str) -> String {
80 s.replace('&', "&")
81 .replace('<', "<")
82 .replace('>', ">")
83}
84
85fn render(
87 usage: &UsageBreakdownResponse,
88 series: &TimeseriesResponse,
89 routed_requests: i64,
90 meta: &ReportMeta,
91) -> String {
92 let t = &usage.totals;
93 let org = meta.org_label.as_deref().unwrap_or("Organization");
94 let window = format!("{} → {}", &usage.from[..10], &usage.to[..10]);
95 let avoided = (t.reference_cost_usd - t.cost_usd).max(0.0);
96
97 let top_by = |key: fn(&super::admin_api::UsageBreakdownRow) -> &str| {
98 let mut agg = std::collections::BTreeMap::<String, (i64, f64, f64)>::new();
99 for r in &usage.rows {
100 let e = agg.entry(key(r).to_string()).or_default();
101 e.0 += r.requests;
102 e.1 += r.cost_usd;
103 e.2 += r.saved_usd;
104 }
105 let mut v: Vec<_> = agg.into_iter().collect();
106 v.sort_by(|a, b| b.1.1.total_cmp(&a.1.1));
107 v.truncate(10);
108 v
109 };
110 let people = top_by(|r| &r.person);
111 let projects = top_by(|r| &r.project);
112 let models = top_by(|r| &r.model);
113
114 let table = |title: &str, rows: &[(String, (i64, f64, f64))]| -> String {
115 let mut out = format!(
116 "<h3>{title}</h3><table><thead><tr><th>{title}</th>\
117 <th class=num>Requests</th><th class=num>Saved</th><th class=num>Cost</th></tr></thead><tbody>"
118 );
119 for (name, (req, cost, saved)) in rows {
120 let _ = write!(
121 out,
122 "<tr><td>{}</td><td class=num>{}</td><td class='num saved'>{}</td><td class=num>{}</td></tr>",
123 esc(name),
124 n(*req),
125 usd(*saved),
126 usd(*cost)
127 );
128 }
129 out.push_str("</tbody></table>");
130 out
131 };
132
133 let projection_block = match (t.projection_seats, t.projection_usd_per_month) {
134 (Some(seats), Some(p)) => format!(
135 "<div class=kpi><div class=kpi-label>Projected org savings</div>\
136 <div class=kpi-value>{}/mo</div><div class=kpi-foot>at {seats} seats — extrapolation, not billing</div></div>",
137 usd(p)
138 ),
139 _ => String::new(),
140 };
141
142 let methodology = meta.reference_model.as_deref().map_or_else(
143 || {
144 "<p>No counterfactual reference model is configured; the avoided-cost \
145 column reports 0. Configure <code>[proxy.baseline] reference_model</code> \
146 to enable avoided-cost accounting.</p>"
147 .to_string()
148 },
149 |m| {
150 format!(
151 "<p>The <b>baseline</b> prices every request's <i>uncompressed</i> input \
152 at the contract-frozen reference model <code>{}</code>. <b>Avoided cost</b> \
153 is the difference between that counterfactual and the actual spend. Local \
154 inference is booked at a transparent shadow rate — savings are never \
155 measured against a free-of-charge fiction.</p>",
156 esc(m)
157 )
158 },
159 );
160
161 format!(
162 r#"<!doctype html>
163<html lang="en"><head><meta charset="utf-8">
164<title>{org_esc} · lean-ctx value report</title>
165<style>
166:root{{--green:#059669;--ink:#111114;--muted:#6b7280;--line:#e5e7eb;--soft:#f5f6f8}}
167*{{margin:0;padding:0;box-sizing:border-box}}
168body{{font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:var(--ink);padding:48px;max-width:960px;margin:0 auto}}
169header{{display:flex;justify-content:space-between;align-items:baseline;border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:28px}}
170h1{{font-size:22px;letter-spacing:-0.02em}}
171h2{{font-size:15px;margin:32px 0 12px;letter-spacing:-0.01em}}
172h3{{font-size:13px;margin:22px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em}}
173.sub{{color:var(--muted);font-size:12px}}
174.kpis{{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:20px 0}}
175.kpi{{border:1px solid var(--line);border-radius:8px;padding:14px 16px;background:var(--soft)}}
176.kpi-label{{font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted)}}
177.kpi-value{{font-size:24px;font-weight:650;font-variant-numeric:tabular-nums;margin-top:4px}}
178.kpi-value.green{{color:var(--green)}}
179.kpi-foot{{font-size:11px;color:var(--muted);margin-top:2px}}
180table{{width:100%;border-collapse:collapse;font-size:12.5px;margin-bottom:8px}}
181th{{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);padding:6px 8px;border-bottom:1px solid var(--ink)}}
182td{{padding:6px 8px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}}
183.num{{text-align:right}}
184.saved{{color:var(--green)}}
185.chart{{margin:12px 0;border:1px solid var(--line);border-radius:8px;padding:12px;background:#fff}}
186.legend{{font-size:11px;color:var(--muted);margin-top:6px}}
187.legend b{{font-weight:600}}
188footer{{margin-top:40px;padding-top:12px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);display:flex;justify-content:space-between}}
189@media print{{body{{padding:24px}}.kpi{{break-inside:avoid}}table{{break-inside:auto}}}}
190</style></head><body>
191<header><div><h1>{org_esc} — AI gateway value report</h1>
192<div class=sub>window {window} · generated by lean-ctx gateway</div></div>
193<div class=sub>lean-ctx v{version}</div></header>
194
195<section class=kpis>
196<div class=kpi><div class=kpi-label>Actual spend</div><div class=kpi-value>{spend}</div><div class=kpi-foot>{requests} requests</div></div>
197<div class=kpi><div class=kpi-label>Verified savings</div><div class="kpi-value green">{saved}</div><div class=kpi-foot>measured per event</div></div>
198<div class=kpi><div class=kpi-label>Baseline (counterfactual)</div><div class=kpi-value>{reference}</div><div class=kpi-foot>uncompressed @ reference model</div></div>
199<div class=kpi><div class=kpi-label>Avoided cost</div><div class="kpi-value green">{avoided}</div><div class=kpi-foot>baseline − actual</div></div>
200{projection_block}
201</section>
202
203<h2>Spend & savings per day</h2>
204<div class=chart>{svg}
205<div class=legend><b>▬</b> spend <b style="color:var(--green)">▬</b> saved <span style="color:#7c3aed"><b>╌</b> baseline</span></div></div>
206
207<h2>Adoption</h2>
208<table><tbody>
209<tr><td>Active people in window</td><td class=num>{persons}</td></tr>
210<tr><td>Requests actively re-routed to cheaper models</td><td class=num>{routed}</td></tr>
211</tbody></table>
212
213<h2>Breakdown</h2>
214{people_table}
215{projects_table}
216{models_table}
217
218<h2>Methodology</h2>
219{methodology}
220
221<footer><span>lean-ctx — SEE · ROUTE · REMEMBER · PROVE</span><span>numbers sourced from usage_events; projection labeled as extrapolation</span></footer>
222</body></html>
223"#,
224 org_esc = esc(org),
225 window = window,
226 version = env!("CARGO_PKG_VERSION"),
227 spend = usd(t.cost_usd),
228 requests = n(t.requests),
229 saved = usd(t.saved_usd),
230 reference = usd(t.reference_cost_usd),
231 avoided = usd(avoided),
232 projection_block = projection_block,
233 svg = trend_svg(series),
234 persons = n(t.active_persons),
235 routed = n(routed_requests),
236 people_table = table("Top people", &people),
237 projects_table = table("Top projects", &projects),
238 models_table = table("Top models", &models),
239 methodology = methodology,
240 )
241}
242
243fn trend_svg(series: &TimeseriesResponse) -> String {
246 const W: f64 = 860.0;
247 const H: f64 = 180.0;
248 const PAD: f64 = 8.0;
249 let points = &series.points;
250 if points.is_empty() {
251 return "<p class=sub>No events in this window.</p>".into();
252 }
253 let max = points
254 .iter()
255 .map(|p| p.cost_usd.max(p.saved_usd).max(p.reference_cost_usd))
256 .fold(0.0_f64, f64::max)
257 .max(1e-9);
258 let count = points.len() as f64;
259 let step = (W - 2.0 * PAD) / count;
260 let bar_w = (step * 0.55).clamp(1.0, 26.0);
261 let y = |v: f64| H - PAD - (v / max) * (H - 2.0 * PAD);
262 let x = |i: usize| PAD + step * (i as f64) + step / 2.0;
263
264 let mut svg = format!(
265 r#"<svg viewBox="0 0 {W} {H}" width="100%" height="{H}" role="img" aria-label="daily spend and savings">"#
266 );
267 for frac in [0.0_f64, 0.5, 1.0] {
269 let gy = y(max * frac);
270 let _ = write!(
271 svg,
272 r##"<line x1="{PAD}" y1="{gy:.1}" x2="{:.1}" y2="{gy:.1}" stroke="#e5e7eb" stroke-width="1"/>"##,
273 W - PAD
274 );
275 }
276 for (i, p) in points.iter().enumerate() {
277 let _ = write!(
278 svg,
279 r##"<rect x="{:.1}" y="{:.1}" width="{bar_w:.1}" height="{:.1}" fill="#94a3b8" rx="1.5"><title>{}: spend {}</title></rect>"##,
280 x(i) - bar_w / 2.0,
281 y(p.cost_usd),
282 (H - PAD - y(p.cost_usd)).max(0.0),
283 p.day,
284 usd(p.cost_usd),
285 );
286 }
287 let polyline = |vals: Vec<f64>, color: &str, dash: &str| -> String {
288 let pts: Vec<String> = vals
289 .iter()
290 .enumerate()
291 .map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v)))
292 .collect();
293 format!(
294 r#"<polyline points="{}" fill="none" stroke="{color}" stroke-width="2"{dash}/>"#,
295 pts.join(" ")
296 )
297 };
298 svg.push_str(&polyline(
299 points.iter().map(|p| p.saved_usd).collect(),
300 "#059669",
301 "",
302 ));
303 if points.iter().any(|p| p.reference_cost_usd > 0.0) {
304 svg.push_str(&polyline(
305 points.iter().map(|p| p.reference_cost_usd).collect(),
306 "#7c3aed",
307 r#" stroke-dasharray="5 4""#,
308 ));
309 }
310 svg.push_str("</svg>");
311 svg
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::gateway_server::admin_api::{UsageBreakdownRow, UsageTotals};
318 use crate::gateway_server::admin_timeseries::TimeseriesPoint;
319
320 fn fixture() -> (UsageBreakdownResponse, TimeseriesResponse) {
321 let usage = UsageBreakdownResponse {
322 from: "2026-06-01T00:00:00+00:00".into(),
323 to: "2026-07-01T00:00:00+00:00".into(),
324 rows: vec![
325 UsageBreakdownRow {
326 person: "alice@zuehlke.com".into(),
327 project: "checkout".into(),
328 model: "claude-sonnet-4-5".into(),
329 provider: "anthropic".into(),
330 requests: 900,
331 input_tokens: 8_000_000,
332 output_tokens: 400_000,
333 cost_usd: 210.0,
334 saved_tokens: 2_500_000,
335 saved_usd: 65.0,
336 measured_requests: 0,
337 estimated_requests: 0,
338 },
339 UsageBreakdownRow {
340 person: "bob@zuehlke.com".into(),
341 project: "platform".into(),
342 model: "phi-4".into(),
343 provider: "foundry".into(),
344 requests: 300,
345 input_tokens: 1_000_000,
346 output_tokens: 90_000,
347 cost_usd: 12.0,
348 saved_tokens: 400_000,
349 saved_usd: 4.0,
350 measured_requests: 0,
351 estimated_requests: 0,
352 },
353 ],
354 totals: UsageTotals {
355 requests: 1200,
356 cost_usd: 222.0,
357 saved_usd: 69.0,
358 reference_cost_usd: 410.0,
359 active_persons: 2,
360 measured_requests: 0,
361 estimated_requests: 0,
362 projection_seats: Some(800),
363 projection_usd_per_month: Some(27_600.0),
364 },
365 };
366 let series = TimeseriesResponse {
367 from: usage.from.clone(),
368 to: usage.to.clone(),
369 points: vec![
370 TimeseriesPoint {
371 day: "2026-06-01".into(),
372 requests: 600,
373 cost_usd: 111.0,
374 saved_usd: 30.0,
375 reference_cost_usd: 205.0,
376 },
377 TimeseriesPoint {
378 day: "2026-06-02".into(),
379 requests: 600,
380 cost_usd: 111.0,
381 saved_usd: 39.0,
382 reference_cost_usd: 205.0,
383 },
384 ],
385 };
386 (usage, series)
387 }
388
389 #[test]
390 fn report_contains_real_numbers_and_no_external_assets() {
391 let (usage, series) = fixture();
392 let html = render(
393 &usage,
394 &series,
395 42,
396 &ReportMeta {
397 org_label: Some("Zühlke Engineering AG".into()),
398 seats: Some(800),
399 reference_model: Some("claude-opus-4.5".into()),
400 },
401 );
402 assert!(html.contains("$222"));
404 assert!(html.contains("$69.00"));
405 assert!(html.contains("$410"));
406 assert!(html.contains("$188")); assert!(html.contains("$27.6k/mo"));
408 assert!(html.contains("alice@zuehlke.com"));
409 assert!(html.contains("claude-opus-4.5"));
410 assert!(html.contains("42")); assert!(!html.contains("src=\"http"));
413 assert!(!html.contains("<script"));
414 assert!(html.contains("<svg"));
415 let hostile = render(
417 &usage,
418 &series,
419 0,
420 &ReportMeta {
421 org_label: Some("<script>alert(1)</script>".into()),
422 seats: None,
423 reference_model: None,
424 },
425 );
426 assert!(!hostile.contains("<script>alert"));
427 }
428
429 #[test]
430 fn empty_window_renders_gracefully() {
431 let usage = UsageBreakdownResponse {
432 from: "2026-06-01T00:00:00+00:00".into(),
433 to: "2026-06-02T00:00:00+00:00".into(),
434 rows: vec![],
435 totals: UsageTotals {
436 requests: 0,
437 cost_usd: 0.0,
438 saved_usd: 0.0,
439 reference_cost_usd: 0.0,
440 active_persons: 0,
441 measured_requests: 0,
442 estimated_requests: 0,
443 projection_seats: None,
444 projection_usd_per_month: None,
445 },
446 };
447 let series = TimeseriesResponse {
448 from: usage.from.clone(),
449 to: usage.to.clone(),
450 points: vec![],
451 };
452 let html = render(
453 &usage,
454 &series,
455 0,
456 &ReportMeta {
457 org_label: None,
458 seats: None,
459 reference_model: None,
460 },
461 );
462 assert!(html.contains("No events in this window"));
463 assert!(html.contains("reference_model"));
464 }
465}