1use std::sync::OnceLock;
23
24use axum::extract::Query;
25use axum::http::{StatusCode, header};
26use axum::response::{IntoResponse, Json, Response};
27use deadpool_postgres::Pool;
28use serde::{Deserialize, Serialize};
29
30use crate::proxy::gateway_identity::GatewayTags;
31
32use super::admin_timeseries::{TimeseriesPoint, fill_gaps};
33
34static USER_POOL: OnceLock<Pool> = OnceLock::new();
35
36pub fn install_pool(pool: Pool) -> bool {
39 USER_POOL.set(pool).is_ok()
40}
41
42const DEFAULT_WINDOW_DAYS: u32 = 30;
44const MAX_WINDOW_DAYS: u32 = 365;
45
46const ME_HTML: &str = include_str!("static/me.html");
49const ME_CSS: &str = include_str!("static/me.css");
50const ME_JS: &str = include_str!("static/me.js");
51const BASE_CSS: &str = include_str!("static/admin.css");
53const ME_FONTS_CSS: &str = include_str!("static/me-fonts.css");
55const FONT_INTER_WOFF2: &[u8] = include_bytes!("../dashboard/static/fonts/inter-variable.woff2");
56const FONT_JETBRAINS_WOFF2: &[u8] =
57 include_bytes!("../dashboard/static/fonts/jetbrains-mono-variable.woff2");
58const FONT_SPACE_GROTESK_WOFF2: &[u8] =
59 include_bytes!("../dashboard/static/fonts/space-grotesk-variable.woff2");
60const VENDOR_CHART_JS: &str = include_str!("../dashboard/static/vendor/chart.umd.min.js");
61
62#[must_use]
65pub fn is_shell_path(path: &str) -> bool {
66 path == "/me" || path.starts_with("/me/static/")
67}
68
69pub fn router<S: Clone + Send + Sync + 'static>() -> axum::Router<S> {
73 axum::Router::new()
74 .route("/me", axum::routing::get(shell))
75 .route("/me/static/base.css", axum::routing::get(base_css))
76 .route("/me/static/me.css", axum::routing::get(me_css))
77 .route("/me/static/me.js", axum::routing::get(me_js))
78 .route("/me/static/fonts/fonts.css", axum::routing::get(fonts_css))
79 .route(
80 "/me/static/vendor/chart.umd.min.js",
81 axum::routing::get(chart_js),
82 )
83 .route("/me/static/fonts/{file}", axum::routing::get(font_file))
84 .route("/api/me/usage", axum::routing::get(me_usage))
85 .layer(axum::middleware::from_fn(super::security::security_headers))
86}
87
88async fn shell() -> impl IntoResponse {
89 (
90 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
91 ME_HTML,
92 )
93}
94
95async fn base_css() -> impl IntoResponse {
96 (
97 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
98 BASE_CSS,
99 )
100}
101
102async fn me_css() -> impl IntoResponse {
103 ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], ME_CSS)
104}
105
106async fn me_js() -> impl IntoResponse {
107 (
108 [(
109 header::CONTENT_TYPE,
110 "application/javascript; charset=utf-8",
111 )],
112 ME_JS,
113 )
114}
115
116async fn fonts_css() -> impl IntoResponse {
117 (
118 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
119 ME_FONTS_CSS,
120 )
121}
122
123async fn chart_js() -> impl IntoResponse {
124 (
125 [(
126 header::CONTENT_TYPE,
127 "application/javascript; charset=utf-8",
128 )],
129 VENDOR_CHART_JS,
130 )
131}
132
133async fn font_file(axum::extract::Path(file): axum::extract::Path<String>) -> Response {
134 let bytes: &'static [u8] = match file.as_str() {
135 "inter-variable.woff2" => FONT_INTER_WOFF2,
136 "jetbrains-mono-variable.woff2" => FONT_JETBRAINS_WOFF2,
137 "space-grotesk-variable.woff2" => FONT_SPACE_GROTESK_WOFF2,
138 _ => return StatusCode::NOT_FOUND.into_response(),
139 };
140 ([(header::CONTENT_TYPE, "font/woff2")], bytes).into_response()
141}
142
143#[derive(Debug, Clone, Deserialize)]
147pub struct MeQuery {
148 pub days: Option<u32>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct MeModelRow {
155 pub model: String,
156 pub provider: String,
157 pub requests: i64,
158 pub input_tokens: i64,
159 pub output_tokens: i64,
160 pub cost_usd: f64,
161 pub saved_usd: f64,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166pub struct MeProjectRow {
167 pub project: String,
168 pub requests: i64,
169 pub cost_usd: f64,
170 pub saved_usd: f64,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175pub struct MeTotals {
176 pub requests: i64,
177 pub input_tokens: i64,
178 pub output_tokens: i64,
179 pub cost_usd: f64,
180 pub saved_tokens: i64,
181 pub saved_usd: f64,
182 pub reference_cost_usd: f64,
184 pub routed_requests: i64,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct MeUsageResponse {
191 pub person: String,
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub team: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub org_label: Option<String>,
198 pub version: String,
199 pub from: String,
200 pub to: String,
201 pub totals: MeTotals,
202 pub by_model: Vec<MeModelRow>,
203 pub by_project: Vec<MeProjectRow>,
204 pub days: Vec<TimeseriesPoint>,
205}
206
207const ME_TOTALS_SQL: &str = "
210SELECT count(*) AS requests,
211 coalesce(sum(input_tokens), 0)::BIGINT AS input_tokens,
212 coalesce(sum(output_tokens), 0)::BIGINT AS output_tokens,
213 coalesce(sum(cost_usd), 0) AS cost_usd,
214 coalesce(sum(saved_tokens), 0)::BIGINT AS saved_tokens,
215 coalesce(sum(saved_usd), 0) AS saved_usd,
216 coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd,
217 count(*) FILTER (WHERE routed_from IS NOT NULL) AS routed_requests
218FROM usage_events
219WHERE ts >= $1 AND ts <= $2 AND person = $3";
220
221const ME_BY_MODEL_SQL: &str = "
222SELECT model, provider,
223 count(*) AS requests,
224 sum(input_tokens)::BIGINT AS input_tokens,
225 sum(output_tokens)::BIGINT AS output_tokens,
226 sum(cost_usd) AS cost_usd,
227 sum(saved_usd) AS saved_usd
228FROM usage_events
229WHERE ts >= $1 AND ts <= $2 AND person = $3
230GROUP BY model, provider
231ORDER BY cost_usd DESC";
232
233const ME_BY_PROJECT_SQL: &str = "
234SELECT project,
235 count(*) AS requests,
236 sum(cost_usd) AS cost_usd,
237 sum(saved_usd) AS saved_usd
238FROM usage_events
239WHERE ts >= $1 AND ts <= $2 AND person = $3
240GROUP BY project
241ORDER BY cost_usd DESC";
242
243const ME_TIMESERIES_SQL: &str = "
244SELECT date_trunc('day', ts) AS day,
245 count(*) AS requests,
246 coalesce(sum(cost_usd), 0) AS cost_usd,
247 coalesce(sum(saved_usd), 0) AS saved_usd,
248 coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd
249FROM usage_events
250WHERE ts >= $1 AND ts <= $2 AND person = $3
251GROUP BY 1
252ORDER BY 1";
253
254async fn me_usage(
258 tags: Option<axum::Extension<GatewayTags>>,
259 Query(q): Query<MeQuery>,
260) -> Response {
261 let Some(person) = tags.as_ref().and_then(|t| t.person.clone()) else {
262 return (
263 StatusCode::FORBIDDEN,
264 Json(serde_json::json!({
265 "error": "this view needs a personal gateway key (it identifies you); \
266 ask your admin for one: lean-ctx gateway keys add --person <you>"
267 })),
268 )
269 .into_response();
270 };
271 let Some(pool) = USER_POOL.get() else {
272 return (
273 StatusCode::SERVICE_UNAVAILABLE,
274 Json(serde_json::json!({
275 "error": "usage store not configured on this gateway (DATABASE_URL unset)"
276 })),
277 )
278 .into_response();
279 };
280 let team = tags.and_then(|t| t.0.team);
281 let days = q
282 .days
283 .unwrap_or(DEFAULT_WINDOW_DAYS)
284 .clamp(1, MAX_WINDOW_DAYS);
285 let to = chrono::Utc::now();
286 let from = to - chrono::Duration::days(i64::from(days));
287
288 match personal_usage(pool, &person, team, from, to).await {
289 Ok(resp) => Json(resp).into_response(),
290 Err(e) => {
291 tracing::warn!("personal usage query failed: {e:#}");
292 (
293 StatusCode::SERVICE_UNAVAILABLE,
294 Json(serde_json::json!({"error": "usage store unavailable"})),
295 )
296 .into_response()
297 }
298 }
299}
300
301pub async fn personal_usage(
306 pool: &Pool,
307 person: &str,
308 team: Option<String>,
309 from: chrono::DateTime<chrono::Utc>,
310 to: chrono::DateTime<chrono::Utc>,
311) -> anyhow::Result<MeUsageResponse> {
312 let client = pool.get().await?;
313
314 let t = client
315 .query_one(ME_TOTALS_SQL, &[&from, &to, &person])
316 .await?;
317 let totals = MeTotals {
318 requests: t.get("requests"),
319 input_tokens: t.get("input_tokens"),
320 output_tokens: t.get("output_tokens"),
321 cost_usd: t.get("cost_usd"),
322 saved_tokens: t.get("saved_tokens"),
323 saved_usd: t.get("saved_usd"),
324 reference_cost_usd: t.get("reference_cost_usd"),
325 routed_requests: t.get("routed_requests"),
326 };
327
328 let by_model = client
329 .query(ME_BY_MODEL_SQL, &[&from, &to, &person])
330 .await?
331 .iter()
332 .map(|r| MeModelRow {
333 model: r.get("model"),
334 provider: r.get("provider"),
335 requests: r.get("requests"),
336 input_tokens: r.get("input_tokens"),
337 output_tokens: r.get("output_tokens"),
338 cost_usd: r.get("cost_usd"),
339 saved_usd: r.get("saved_usd"),
340 })
341 .collect();
342
343 let by_project = client
344 .query(ME_BY_PROJECT_SQL, &[&from, &to, &person])
345 .await?
346 .iter()
347 .map(|r| MeProjectRow {
348 project: r.get("project"),
349 requests: r.get("requests"),
350 cost_usd: r.get("cost_usd"),
351 saved_usd: r.get("saved_usd"),
352 })
353 .collect();
354
355 let measured: Vec<TimeseriesPoint> = client
356 .query(ME_TIMESERIES_SQL, &[&from, &to, &person])
357 .await?
358 .iter()
359 .map(|r| {
360 let day: chrono::DateTime<chrono::Utc> = r.get("day");
361 TimeseriesPoint {
362 day: day.format("%Y-%m-%d").to_string(),
363 requests: r.get("requests"),
364 cost_usd: r.get("cost_usd"),
365 saved_usd: r.get("saved_usd"),
366 reference_cost_usd: r.get("reference_cost_usd"),
367 }
368 })
369 .collect();
370
371 let cfg = crate::core::config::Config::load();
372 Ok(MeUsageResponse {
373 person: person.to_string(),
374 team,
375 org_label: cfg.gateway_server.org_label.clone(),
376 version: env!("CARGO_PKG_VERSION").to_string(),
377 from: from.to_rfc3339(),
378 to: to.to_rfc3339(),
379 totals,
380 by_model,
381 by_project,
382 days: fill_gaps(&measured, from, to),
383 })
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn shell_paths_cover_exactly_the_public_surface() {
392 assert!(is_shell_path("/me"));
393 assert!(is_shell_path("/me/static/me.js"));
394 assert!(is_shell_path("/me/static/fonts/inter-variable.woff2"));
395 assert!(!is_shell_path("/api/me/usage"));
397 assert!(!is_shell_path("/me2"));
398 assert!(!is_shell_path("/mex/static/a.js"));
399 assert!(!is_shell_path("/v1/messages"));
400 }
401
402 #[test]
403 fn embedded_assets_are_nonempty_and_wired() {
404 assert!(ME_HTML.contains("<!doctype html"));
405 assert!(
406 ME_HTML.contains("/me/static/me.js"),
407 "shell must load the app script"
408 );
409 assert!(
410 ME_HTML.contains("/me/static/base.css"),
411 "shell must reuse the console design system"
412 );
413 assert!(
414 ME_JS.contains("/api/me/usage"),
415 "app must talk to the guarded API"
416 );
417 assert!(
418 ME_FONTS_CSS.contains("/me/static/fonts/"),
419 "font faces must resolve on the proxy port"
420 );
421 assert!(!ME_CSS.is_empty());
422 assert!(!VENDOR_CHART_JS.is_empty());
423 }
424
425 #[test]
426 fn shell_never_embeds_credentials() {
427 for needle in ["Bearer ", "gk-", "LEAN_CTX_PROXY_TOKEN="] {
428 assert!(!ME_HTML.contains(needle), "me.html must not embed {needle}");
429 }
430 assert!(
431 !ME_JS.contains("localStorage.setItem('leanctx-me-key'"),
432 "key must live in sessionStorage, not persist in localStorage"
433 );
434 }
435
436 #[test]
437 fn window_days_are_clamped() {
438 for (input, expected) in [
439 (None, DEFAULT_WINDOW_DAYS),
440 (Some(0), 1),
441 (Some(7), 7),
442 (Some(9999), MAX_WINDOW_DAYS),
443 ] {
444 let days = input
445 .unwrap_or(DEFAULT_WINDOW_DAYS)
446 .clamp(1, MAX_WINDOW_DAYS);
447 assert_eq!(days, expected, "input {input:?}");
448 }
449 }
450
451 #[test]
452 fn response_shape_round_trips() {
453 let resp = MeUsageResponse {
455 person: "alice@zuehlke.com".into(),
456 team: Some("platform".into()),
457 org_label: Some("Zühlke Engineering AG".into()),
458 version: "3.8.18".into(),
459 from: "2026-06-03T00:00:00+00:00".into(),
460 to: "2026-07-03T00:00:00+00:00".into(),
461 totals: MeTotals {
462 requests: 412,
463 input_tokens: 9_000_000,
464 output_tokens: 310_000,
465 cost_usd: 84.12,
466 saved_tokens: 2_400_000,
467 saved_usd: 41.90,
468 reference_cost_usd: 190.55,
469 routed_requests: 96,
470 },
471 by_model: vec![MeModelRow {
472 model: "zuehlke/fast".into(),
473 provider: "foundry".into(),
474 requests: 96,
475 input_tokens: 1_000_000,
476 output_tokens: 50_000,
477 cost_usd: 4.20,
478 saved_usd: 12.80,
479 }],
480 by_project: vec![MeProjectRow {
481 project: "checkout".into(),
482 requests: 412,
483 cost_usd: 84.12,
484 saved_usd: 41.90,
485 }],
486 days: vec![],
487 };
488 let json = serde_json::to_value(&resp).expect("serializes");
489 assert_eq!(json["person"], "alice@zuehlke.com");
490 assert_eq!(json["totals"]["routed_requests"], 96);
491 assert_eq!(json["by_model"][0]["model"], "zuehlke/fast");
492 let parsed: MeUsageResponse = serde_json::from_value(json).expect("round-trips");
493 assert_eq!(parsed, resp);
494 }
495
496 #[tokio::test]
497 async fn me_usage_refuses_identityless_tokens() {
498 for tags in [
500 None,
501 Some(axum::Extension(GatewayTags::default())),
502 Some(axum::Extension(GatewayTags {
503 person: None,
504 team: None,
505 project: Some("side-quest".into()),
506 })),
507 ] {
508 let resp = me_usage(tags, Query(MeQuery { days: Some(7) })).await;
509 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
510 }
511 }
512
513 #[tokio::test]
514 async fn me_usage_without_store_is_503() {
515 let tags = Some(axum::Extension(GatewayTags {
519 person: Some("alice".into()),
520 team: None,
521 project: None,
522 }));
523 let resp = me_usage(tags, Query(MeQuery { days: None })).await;
524 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
525 }
526}