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)]
176pub struct MeToolRow {
177 pub server_id: String,
178 pub tool: String,
179 pub calls: i64,
180 pub result_tokens: i64,
181 pub context_cost_usd: f64,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct MeTotals {
187 pub requests: i64,
188 pub input_tokens: i64,
189 pub output_tokens: i64,
190 pub cost_usd: f64,
191 pub saved_tokens: i64,
192 pub saved_usd: f64,
193 pub reference_cost_usd: f64,
195 pub routed_requests: i64,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct MeUsageResponse {
202 pub person: String,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub team: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub org_label: Option<String>,
209 pub version: String,
210 pub from: String,
211 pub to: String,
212 pub totals: MeTotals,
213 pub by_model: Vec<MeModelRow>,
214 pub by_project: Vec<MeProjectRow>,
215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
218 pub tools: Vec<MeToolRow>,
219 pub days: Vec<TimeseriesPoint>,
220}
221
222const ME_TOTALS_SQL: &str = "
225SELECT count(*) AS requests,
226 coalesce(sum(input_tokens), 0)::BIGINT AS input_tokens,
227 coalesce(sum(output_tokens), 0)::BIGINT AS output_tokens,
228 coalesce(sum(cost_usd), 0) AS cost_usd,
229 coalesce(sum(saved_tokens), 0)::BIGINT AS saved_tokens,
230 coalesce(sum(saved_usd), 0) AS saved_usd,
231 coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd,
232 count(*) FILTER (WHERE routed_from IS NOT NULL) AS routed_requests
233FROM usage_events
234WHERE ts >= $1 AND ts <= $2 AND person = $3";
235
236const ME_BY_MODEL_SQL: &str = "
237SELECT model, provider,
238 count(*) AS requests,
239 sum(input_tokens)::BIGINT AS input_tokens,
240 sum(output_tokens)::BIGINT AS output_tokens,
241 sum(cost_usd) AS cost_usd,
242 sum(saved_usd) AS saved_usd
243FROM usage_events
244WHERE ts >= $1 AND ts <= $2 AND person = $3
245GROUP BY model, provider
246ORDER BY cost_usd DESC";
247
248const ME_BY_PROJECT_SQL: &str = "
249SELECT project,
250 count(*) AS requests,
251 sum(cost_usd) AS cost_usd,
252 sum(saved_usd) AS saved_usd
253FROM usage_events
254WHERE ts >= $1 AND ts <= $2 AND person = $3
255GROUP BY project
256ORDER BY cost_usd DESC";
257
258const ME_TIMESERIES_SQL: &str = "
259SELECT date_trunc('day', ts) AS day,
260 count(*) AS requests,
261 coalesce(sum(cost_usd), 0) AS cost_usd,
262 coalesce(sum(saved_usd), 0) AS saved_usd,
263 coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd
264FROM usage_events
265WHERE ts >= $1 AND ts <= $2 AND person = $3
266GROUP BY 1
267ORDER BY 1";
268
269async fn me_usage(
273 tags: Option<axum::Extension<GatewayTags>>,
274 Query(q): Query<MeQuery>,
275) -> Response {
276 let Some(person) = tags.as_ref().and_then(|t| t.person.clone()) else {
277 return (
278 StatusCode::FORBIDDEN,
279 Json(serde_json::json!({
280 "error": "this view needs a personal gateway key (it identifies you); \
281 ask your admin for one: lean-ctx gateway keys add --person <you>"
282 })),
283 )
284 .into_response();
285 };
286 let Some(pool) = USER_POOL.get() else {
287 return (
288 StatusCode::SERVICE_UNAVAILABLE,
289 Json(serde_json::json!({
290 "error": "usage store not configured on this gateway (DATABASE_URL unset)"
291 })),
292 )
293 .into_response();
294 };
295 let team = tags.and_then(|t| t.0.team);
296 let days = q
297 .days
298 .unwrap_or(DEFAULT_WINDOW_DAYS)
299 .clamp(1, MAX_WINDOW_DAYS);
300 let to = chrono::Utc::now();
301 let from = to - chrono::Duration::days(i64::from(days));
302
303 match personal_usage(pool, &person, team, from, to).await {
304 Ok(resp) => Json(resp).into_response(),
305 Err(e) => {
306 tracing::warn!("personal usage query failed: {e:#}");
307 (
308 StatusCode::SERVICE_UNAVAILABLE,
309 Json(serde_json::json!({"error": "usage store unavailable"})),
310 )
311 .into_response()
312 }
313 }
314}
315
316pub async fn personal_usage(
321 pool: &Pool,
322 person: &str,
323 team: Option<String>,
324 from: chrono::DateTime<chrono::Utc>,
325 to: chrono::DateTime<chrono::Utc>,
326) -> anyhow::Result<MeUsageResponse> {
327 let client = pool.get().await?;
328
329 let t = client
330 .query_one(ME_TOTALS_SQL, &[&from, &to, &person])
331 .await?;
332 let totals = MeTotals {
333 requests: t.get("requests"),
334 input_tokens: t.get("input_tokens"),
335 output_tokens: t.get("output_tokens"),
336 cost_usd: t.get("cost_usd"),
337 saved_tokens: t.get("saved_tokens"),
338 saved_usd: t.get("saved_usd"),
339 reference_cost_usd: t.get("reference_cost_usd"),
340 routed_requests: t.get("routed_requests"),
341 };
342
343 let by_model = client
344 .query(ME_BY_MODEL_SQL, &[&from, &to, &person])
345 .await?
346 .iter()
347 .map(|r| MeModelRow {
348 model: r.get("model"),
349 provider: r.get("provider"),
350 requests: r.get("requests"),
351 input_tokens: r.get("input_tokens"),
352 output_tokens: r.get("output_tokens"),
353 cost_usd: r.get("cost_usd"),
354 saved_usd: r.get("saved_usd"),
355 })
356 .collect();
357
358 let by_project = client
359 .query(ME_BY_PROJECT_SQL, &[&from, &to, &person])
360 .await?
361 .iter()
362 .map(|r| MeProjectRow {
363 project: r.get("project"),
364 requests: r.get("requests"),
365 cost_usd: r.get("cost_usd"),
366 saved_usd: r.get("saved_usd"),
367 })
368 .collect();
369
370 let tools = client
373 .query(super::mcp::store::ME_TOOLS_SQL, &[&from, &to, &person])
374 .await
375 .map(|rows| {
376 rows.iter()
377 .map(|r| MeToolRow {
378 server_id: r.get("server_id"),
379 tool: r.get("tool"),
380 calls: r.get("calls"),
381 result_tokens: r.get("result_tokens"),
382 context_cost_usd: r.get("context_cost_usd"),
383 })
384 .collect()
385 })
386 .unwrap_or_default();
387
388 let measured: Vec<TimeseriesPoint> = client
389 .query(ME_TIMESERIES_SQL, &[&from, &to, &person])
390 .await?
391 .iter()
392 .map(|r| {
393 let day: chrono::DateTime<chrono::Utc> = r.get("day");
394 TimeseriesPoint {
395 day: day.format("%Y-%m-%d").to_string(),
396 requests: r.get("requests"),
397 cost_usd: r.get("cost_usd"),
398 saved_usd: r.get("saved_usd"),
399 reference_cost_usd: r.get("reference_cost_usd"),
400 }
401 })
402 .collect();
403
404 let cfg = crate::core::config::Config::load();
405 Ok(MeUsageResponse {
406 person: person.to_string(),
407 team,
408 org_label: cfg.gateway_server.org_label.clone(),
409 version: env!("CARGO_PKG_VERSION").to_string(),
410 from: from.to_rfc3339(),
411 to: to.to_rfc3339(),
412 totals,
413 by_model,
414 by_project,
415 tools,
416 days: fill_gaps(&measured, from, to),
417 })
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn shell_paths_cover_exactly_the_public_surface() {
426 assert!(is_shell_path("/me"));
427 assert!(is_shell_path("/me/static/me.js"));
428 assert!(is_shell_path("/me/static/fonts/inter-variable.woff2"));
429 assert!(!is_shell_path("/api/me/usage"));
431 assert!(!is_shell_path("/me2"));
432 assert!(!is_shell_path("/mex/static/a.js"));
433 assert!(!is_shell_path("/v1/messages"));
434 }
435
436 #[test]
437 fn embedded_assets_are_nonempty_and_wired() {
438 assert!(ME_HTML.contains("<!doctype html"));
439 assert!(
440 ME_HTML.contains("/me/static/me.js"),
441 "shell must load the app script"
442 );
443 assert!(
444 ME_HTML.contains("/me/static/base.css"),
445 "shell must reuse the console design system"
446 );
447 assert!(
448 ME_JS.contains("/api/me/usage"),
449 "app must talk to the guarded API"
450 );
451 assert!(
452 ME_FONTS_CSS.contains("/me/static/fonts/"),
453 "font faces must resolve on the proxy port"
454 );
455 assert!(!ME_CSS.is_empty());
456 assert!(!VENDOR_CHART_JS.is_empty());
457 }
458
459 #[test]
460 fn shell_never_embeds_credentials() {
461 for needle in ["Bearer ", "gk-", "LEAN_CTX_PROXY_TOKEN="] {
462 assert!(!ME_HTML.contains(needle), "me.html must not embed {needle}");
463 }
464 assert!(
465 !ME_JS.contains("localStorage.setItem('leanctx-me-key'"),
466 "key must live in sessionStorage, not persist in localStorage"
467 );
468 }
469
470 #[test]
471 fn window_days_are_clamped() {
472 for (input, expected) in [
473 (None, DEFAULT_WINDOW_DAYS),
474 (Some(0), 1),
475 (Some(7), 7),
476 (Some(9999), MAX_WINDOW_DAYS),
477 ] {
478 let days = input
479 .unwrap_or(DEFAULT_WINDOW_DAYS)
480 .clamp(1, MAX_WINDOW_DAYS);
481 assert_eq!(days, expected, "input {input:?}");
482 }
483 }
484
485 #[test]
486 fn response_shape_round_trips() {
487 let resp = MeUsageResponse {
489 person: "alice@zuehlke.com".into(),
490 team: Some("platform".into()),
491 org_label: Some("Zühlke Engineering AG".into()),
492 version: "3.8.18".into(),
493 from: "2026-06-03T00:00:00+00:00".into(),
494 to: "2026-07-03T00:00:00+00:00".into(),
495 totals: MeTotals {
496 requests: 412,
497 input_tokens: 9_000_000,
498 output_tokens: 310_000,
499 cost_usd: 84.12,
500 saved_tokens: 2_400_000,
501 saved_usd: 41.90,
502 reference_cost_usd: 190.55,
503 routed_requests: 96,
504 },
505 by_model: vec![MeModelRow {
506 model: "zuehlke/fast".into(),
507 provider: "foundry".into(),
508 requests: 96,
509 input_tokens: 1_000_000,
510 output_tokens: 50_000,
511 cost_usd: 4.20,
512 saved_usd: 12.80,
513 }],
514 by_project: vec![MeProjectRow {
515 project: "checkout".into(),
516 requests: 412,
517 cost_usd: 84.12,
518 saved_usd: 41.90,
519 }],
520 tools: vec![MeToolRow {
521 server_id: "github".into(),
522 tool: "get_issue".into(),
523 calls: 31,
524 result_tokens: 128_000,
525 context_cost_usd: 0.32,
526 }],
527 days: vec![],
528 };
529 let json = serde_json::to_value(&resp).expect("serializes");
530 assert_eq!(json["person"], "alice@zuehlke.com");
531 assert_eq!(json["totals"]["routed_requests"], 96);
532 assert_eq!(json["by_model"][0]["model"], "zuehlke/fast");
533 let parsed: MeUsageResponse = serde_json::from_value(json).expect("round-trips");
534 assert_eq!(parsed, resp);
535 }
536
537 #[tokio::test]
538 async fn me_usage_refuses_identityless_tokens() {
539 for tags in [
541 None,
542 Some(axum::Extension(GatewayTags::default())),
543 Some(axum::Extension(GatewayTags {
544 person: None,
545 team: None,
546 project: Some("side-quest".into()),
547 })),
548 ] {
549 let resp = me_usage(tags, Query(MeQuery { days: Some(7) })).await;
550 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
551 }
552 }
553
554 #[tokio::test]
555 async fn me_usage_without_store_is_503() {
556 let tags = Some(axum::Extension(GatewayTags {
560 person: Some("alice".into()),
561 team: None,
562 project: None,
563 }));
564 let resp = me_usage(tags, Query(MeQuery { days: None })).await;
565 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
566 }
567}