Skip to main content

otelite_api/api/
genai.rs

1//! GenAI/LLM token usage API endpoints
2
3use crate::server::AppState;
4use axum::{
5    extract::{Query, State},
6    http::StatusCode,
7    response::Json,
8};
9use otelite_core::api::{
10    AgentRolesResponse, AgentRollup, AgentRollupResponse, ConversationCostRow,
11    ConversationDepthStats, CostSeriesPoint, ErrorResponse, GenAiCapabilityResponse,
12    LatencyPercentilesResponse, ProjectRollupResponse, ProviderMixResponse, ReasoningShareResponse,
13    RequestParamProfile, RetrievalStats, RetryStats, SessionCostRow, TokenUsageResponse,
14    ToolApprovalStats, TopSpan, TopSpanSort,
15};
16use otelite_core::filters::{GenAiFilters, FILTER_DIMENSIONS};
17use otelite_core::pricing::{PricingDatabase, TokenUsage};
18use serde::{Deserialize, Serialize};
19
20/// Every GenAI endpoint accepts the five filter-bar dimensions as query
21/// params (#135); each endpoint applies the subset it genuinely supports
22/// and echoes that set back as `filters_applied`. Unsupported params are
23/// ignored — never a 400.
24/// Build a `GenAiFilters` from a query struct carrying the five filter
25/// fields.
26macro_rules! genai_filter_impl {
27    ($t:ident) => {
28        impl $t {
29            /// Build the effective filter set from this query (borrowing —
30            /// the handler still needs the raw query fields).
31            fn filters(&self) -> GenAiFilters {
32                GenAiFilters {
33                    agent: self.agent.clone(),
34                    model: self.model.clone(),
35                    // The filter bar sends a single exact model; the
36                    // repeatable `models` patterns are CLI-only for now.
37                    models: None,
38                    provider: self.provider.clone(),
39                    project: self.project.clone(),
40                    session: self.session.clone(),
41                }
42            }
43        }
44    };
45}
46
47/// Wrapper for array-shaped GenAI responses so every endpoint can echo the
48/// filter dimensions it actually applied (`filters_applied`).
49#[derive(Debug, Serialize, utoipa::ToSchema)]
50pub struct GenAiItemsResponse {
51    /// Response items (the same rows the endpoint returned before #135)
52    pub items: serde_json::Value,
53    /// Filter dimensions this endpoint actually applied
54    pub filters_applied: Vec<String>,
55}
56
57/// Enrich a batch of TopSpan rows with computed cost fields.
58fn enrich_top_spans(rows: &mut [TopSpan], db: &PricingDatabase) {
59    for row in rows {
60        let usage = TokenUsage {
61            input: row.input_tokens,
62            output: row.output_tokens,
63            cache_creation: row.cache_creation_tokens,
64            cache_read: row.cache_read_tokens,
65        };
66        let result = db.compute_cost(row.model.as_deref(), usage, row.system.as_deref());
67        row.cost = result.cost;
68        row.cost_source = Some(result.source.as_str().to_string());
69        row.cost_reason = result.reason;
70        let duration_ms = row.duration / 1_000_000;
71        if row.output_tokens > 0 && duration_ms > 0 {
72            row.derived_output_tokens_per_sec =
73                Some(row.output_tokens as f64 / (duration_ms as f64 / 1000.0));
74        }
75    }
76}
77
78/// Enrich cost-series bucket rows. Cost is computed per-bucket using the
79/// bucket's aggregate token counts and the model that dominates the bucket.
80/// Provider isn't carried at the bucket level so we pass `None` for system —
81/// the fallback table matches on model name alone.
82fn enrich_cost_series(rows: &mut [CostSeriesPoint], db: &PricingDatabase) {
83    for row in rows {
84        let usage = TokenUsage {
85            input: row.input_tokens,
86            output: row.output_tokens,
87            cache_creation: row.cache_creation_tokens,
88            cache_read: row.cache_read_tokens,
89        };
90        let result = db.compute_cost(row.model.as_deref(), usage, None);
91        row.cost = result.cost;
92        row.cost_source = Some(result.source.as_str().to_string());
93    }
94}
95
96/// Query parameters for token usage endpoint
97#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
98pub struct TokenUsageQuery {
99    /// Start time (nanoseconds since Unix epoch)
100    pub start_time: Option<i64>,
101    /// End time (nanoseconds since Unix epoch)
102    pub end_time: Option<i64>,
103    /// Agent family filter: `claude`, `opencode`, or `codex`
104    pub agent: Option<String>,
105    /// Model name filter
106    pub model: Option<String>,
107    /// Provider filter
108    pub provider: Option<String>,
109    /// Project id filter (opencode data only)
110    pub project: Option<String>,
111    /// Session id filter
112    pub session: Option<String>,
113}
114
115/// Get token usage statistics for GenAI/LLM spans
116///
117/// Returns aggregated token usage grouped by model and system (provider).
118/// Only includes spans with `gen_ai.system` attribute.
119#[utoipa::path(
120    get,
121    path = "/api/genai/usage",
122    params(TokenUsageQuery),
123    responses(
124        (status = 200, description = "Token usage summary", body = TokenUsageResponse),
125        (status = 500, description = "Internal server error", body = ErrorResponse)
126    ),
127    tag = "genai"
128)]
129pub async fn get_token_usage(
130    State(state): State<AppState>,
131    Query(query): Query<TokenUsageQuery>,
132) -> Result<Json<TokenUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
133    let filters = query.filters();
134    let (summary, by_model, by_system) = state
135        .storage
136        .query_token_usage(query.start_time, query.end_time, &filters)
137        .await
138        .map_err(|e| {
139            (
140                StatusCode::INTERNAL_SERVER_ERROR,
141                Json(ErrorResponse::storage_error(format!(
142                    "query token usage: {}",
143                    e
144                ))),
145            )
146        })?;
147
148    Ok(Json(TokenUsageResponse {
149        summary,
150        by_model,
151        by_system,
152        filters_applied: filters.applied(&FILTER_DIMENSIONS),
153    }))
154}
155
156/// Query parameters for cost-over-time endpoint
157#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
158pub struct CostSeriesQuery {
159    /// Start time (nanoseconds since Unix epoch)
160    pub start_time: Option<i64>,
161    /// End time (nanoseconds since Unix epoch)
162    pub end_time: Option<i64>,
163    /// Bucket size in seconds (defaults to 3600 = 1 hour)
164    pub bucket: Option<i64>,
165    /// Agent family filter: `claude`, `opencode`, or `codex`
166    pub agent: Option<String>,
167    /// Model name filter
168    pub model: Option<String>,
169    /// Provider filter
170    pub provider: Option<String>,
171    /// Project id filter (opencode data only)
172    pub project: Option<String>,
173    /// Session id filter
174    pub session: Option<String>,
175}
176
177/// Get time-bucketed token usage (cost-over-time)
178///
179/// Aggregates input/output/cache tokens and request counts into fixed-size time buckets
180/// grouped by model. Use for charting cost trends.
181#[utoipa::path(
182    get,
183    path = "/api/genai/cost_series",
184    params(CostSeriesQuery),
185    responses(
186        (status = 200, description = "Cost series points", body = GenAiItemsResponse),
187        (status = 400, description = "Invalid bucket parameter", body = ErrorResponse),
188        (status = 500, description = "Internal server error", body = ErrorResponse)
189    ),
190    tag = "genai"
191)]
192pub async fn get_cost_series(
193    State(state): State<AppState>,
194    Query(query): Query<CostSeriesQuery>,
195) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
196    let bucket_seconds = query.bucket.unwrap_or(3600);
197    if bucket_seconds <= 0 {
198        return Err((
199            StatusCode::BAD_REQUEST,
200            Json(ErrorResponse::bad_request(
201                "bucket must be a positive number of seconds",
202            )),
203        ));
204    }
205    let bucket_ns = bucket_seconds.saturating_mul(1_000_000_000);
206    let filters = query.filters();
207
208    let mut series = state
209        .storage
210        .query_cost_series(query.start_time, query.end_time, bucket_ns, &filters)
211        .await
212        .map_err(|e| {
213            (
214                StatusCode::INTERNAL_SERVER_ERROR,
215                Json(ErrorResponse::storage_error(format!(
216                    "query cost series: {}",
217                    e
218                ))),
219            )
220        })?;
221
222    let pricing = state.pricing.snapshot().await;
223    enrich_cost_series(&mut series, &pricing.db);
224
225    Ok(Json(GenAiItemsResponse {
226        items: serde_json::to_value(&series).map_err(|e| {
227            (
228                StatusCode::INTERNAL_SERVER_ERROR,
229                Json(ErrorResponse::storage_error(format!(
230                    "serialize cost series: {e}"
231                ))),
232            )
233        })?,
234        filters_applied: filters.applied(&FILTER_DIMENSIONS),
235    }))
236}
237
238/// Query parameters for the cost projection endpoint (#170): the window
239/// is always a rolling 30 days ending at `end_time` (default: now).
240#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
241pub struct ProjectionQuery {
242    /// As-of instant (nanoseconds since Unix epoch). The projection rolls
243    /// back 30 days from here; defaults to now.
244    pub end_time: Option<i64>,
245}
246
247/// Monthly cost projection at the trailing 7-day rate (#170): where the
248/// current month lands if recent spend holds. The 30-day rolling window
249/// is fixed so the 30-day average is always defined.
250#[utoipa::path(
251    get,
252    path = "/api/genai/cost_projection",
253    params(ProjectionQuery),
254    responses(
255        (status = 200, description = "Projected monthly spend at the trailing 7-day rate", body = otelite_core::api::CostProjectionResponse),
256        (status = 500, description = "Internal server error", body = ErrorResponse)
257    ),
258    tag = "genai"
259)]
260pub async fn get_cost_projection(
261    State(state): State<AppState>,
262    Query(query): Query<ProjectionQuery>,
263) -> Result<Json<otelite_core::api::CostProjectionResponse>, (StatusCode, Json<ErrorResponse>)> {
264    const DAY_NS: i64 = 86_400 * 1_000_000_000;
265    let as_of_ns = query
266        .end_time
267        .unwrap_or_else(|| chrono::Utc::now().timestamp_nanos_opt().unwrap_or(i64::MAX));
268    let window_start = as_of_ns - 30 * DAY_NS;
269    let filters = GenAiFilters::default();
270
271    let mut series = state
272        .storage
273        .query_cost_series(Some(window_start), Some(as_of_ns), DAY_NS, &filters)
274        .await
275        .map_err(|e| {
276            (
277                StatusCode::INTERNAL_SERVER_ERROR,
278                Json(ErrorResponse::storage_error(format!(
279                    "query cost series for projection: {e}"
280                ))),
281            )
282        })?;
283
284    let pricing = state.pricing.snapshot().await;
285    enrich_cost_series(&mut series, &pricing.db);
286
287    Ok(Json(otelite_core::cost_projection::compute(
288        &series, as_of_ns,
289    )))
290}
291
292/// LLM cost by project × tool × model (#173). Codex/Claude Code spans
293/// carry no project label and land in "unattributed" — a known
294/// limitation documented in the UI.
295#[utoipa::path(
296    get,
297    path = "/api/genai/cost_by_project",
298    params(TimeRangeQuery),
299    responses(
300        (status = 200, description = "Cost rows grouped by (project, tool, model)", body = otelite_core::api::CostByProjectResponse),
301        (status = 500, description = "Internal server error", body = ErrorResponse)
302    ),
303    tag = "genai"
304)]
305pub async fn get_cost_by_project(
306    State(state): State<AppState>,
307    Query(query): Query<TimeRangeQuery>,
308) -> Result<Json<otelite_core::api::CostByProjectResponse>, (StatusCode, Json<ErrorResponse>)> {
309    let mut response = state
310        .storage
311        .query_cost_by_project_model_tool(query.start_time, query.end_time)
312        .await
313        .map_err(|e| {
314            (
315                StatusCode::INTERNAL_SERVER_ERROR,
316                Json(ErrorResponse::storage_error(format!(
317                    "query cost_by_project: {e}"
318                ))),
319            )
320        })?;
321
322    // Price each (project, tool, model) row; unpriced models keep a
323    // `None` cost — never a fabricated zero.
324    let pricing = state.pricing.snapshot().await;
325    for r in &mut response.rows {
326        let usage = TokenUsage {
327            input: r.input_tokens,
328            output: r.output_tokens,
329            cache_creation: r.cache_creation_tokens,
330            cache_read: r.cache_read_tokens,
331        };
332        let result = pricing.db.compute_cost(Some(r.model.as_str()), usage, None);
333        r.cost_usd = result.cost;
334        r.cost_source = Some(result.source.as_str().to_string());
335    }
336
337    Ok(Json(response))
338}
339
340/// Session depth vs cost (#180): per (tool, turn-count bucket) median /
341/// p95 session cost — where does cost explode as sessions get longer?
342#[utoipa::path(
343    get,
344    path = "/api/genai/session_depth_cost",
345    params(TimeRangeQuery),
346    responses(
347        (status = 200, description = "Median/p95 session cost by tool and turn-count bucket", body = otelite_core::api::SessionDepthCostResponse),
348        (status = 500, description = "Internal server error", body = ErrorResponse)
349    ),
350    tag = "genai"
351)]
352pub async fn get_session_depth_cost(
353    State(state): State<AppState>,
354    Query(query): Query<TimeRangeQuery>,
355) -> Result<Json<otelite_core::api::SessionDepthCostResponse>, (StatusCode, Json<ErrorResponse>)> {
356    use std::collections::HashMap;
357
358    let storage_resp = state
359        .storage
360        .query_session_depth_storage(query.start_time, query.end_time)
361        .await
362        .map_err(|e| {
363            (
364                StatusCode::INTERNAL_SERVER_ERROR,
365                Json(ErrorResponse::storage_error(format!(
366                    "query session_depth_cost: {e}"
367                ))),
368            )
369        })?;
370
371    // Fold (session, model) rows into per-session (turns, priced cost).
372    // A session with at least one priced model row carries a cost; the
373    // unpriced remainder is simply absent from the sum.
374    let mut sessions: HashMap<(String, String), (u64, Option<f64>)> = HashMap::new();
375    let pricing = state.pricing.snapshot().await;
376    for r in &storage_resp.rows {
377        let usage = TokenUsage {
378            input: r.input_tokens,
379            output: r.output_tokens,
380            cache_creation: r.cache_creation_tokens,
381            cache_read: r.cache_read_tokens,
382        };
383        let result = pricing.db.compute_cost(Some(r.model.as_str()), usage, None);
384        let entry = sessions
385            .entry((r.session_id.clone(), r.tool.clone()))
386            .or_insert((0, None));
387        entry.0 += r.turns;
388        if let Some(c) = result.cost {
389            entry.1 = Some(entry.1.unwrap_or(0.0) + c);
390        }
391    }
392    let inputs: Vec<otelite_core::api::SessionDepthSession> = sessions
393        .into_iter()
394        .map(|((session_id, tool), (turn_count, cost_usd))| {
395            otelite_core::api::SessionDepthSession {
396                session_id,
397                tool,
398                turn_count,
399                cost_usd,
400            }
401        })
402        .collect();
403
404    let rows = otelite_core::session_depth::bucket_stats(&inputs);
405    Ok(Json(otelite_core::api::SessionDepthCostResponse {
406        rows,
407        filters_applied: storage_resp.filters_applied,
408    }))
409}
410
411/// Query parameters for the time-in-tool endpoint (#172).
412#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
413pub struct TimeInToolQuery {
414    /// Start time (nanoseconds since Unix epoch)
415    pub start_time: Option<i64>,
416    /// End time (nanoseconds since Unix epoch)
417    pub end_time: Option<i64>,
418    /// Engagement gap ceiling in seconds. Gaps at or below it count in
419    /// full; longer gaps are context switches and count zero. Default 300.
420    pub max_gap_secs: Option<u64>,
421}
422
423/// Active engagement time per tool per UTC day (#172): inter-LLM-span
424/// gaps within sessions, with gaps longer than the ceiling treated as
425/// context switches (they contribute zero, not a clamped amount).
426#[utoipa::path(
427    get,
428    path = "/api/genai/time_in_tool",
429    params(TimeInToolQuery),
430    responses(
431        (status = 200, description = "Active engagement minutes per tool per UTC day", body = otelite_core::api::TimeInToolResponse),
432        (status = 400, description = "Invalid max_gap_secs", body = ErrorResponse),
433        (status = 500, description = "Internal server error", body = ErrorResponse)
434    ),
435    tag = "genai"
436)]
437pub async fn get_time_in_tool(
438    State(state): State<AppState>,
439    Query(query): Query<TimeInToolQuery>,
440) -> Result<Json<otelite_core::api::TimeInToolResponse>, (StatusCode, Json<ErrorResponse>)> {
441    const DEFAULT_MAX_GAP_SECS: u64 = 300;
442    const MAX_GAP_SECS_LIMIT: u64 = 86_400;
443    let max_gap_secs = query.max_gap_secs.unwrap_or(DEFAULT_MAX_GAP_SECS);
444    if max_gap_secs == 0 || max_gap_secs > MAX_GAP_SECS_LIMIT {
445        return Err((
446            StatusCode::BAD_REQUEST,
447            Json(ErrorResponse::bad_request(format!(
448                "max_gap_secs must be between 1 and {MAX_GAP_SECS_LIMIT} seconds, got {max_gap_secs}"
449            ))),
450        ));
451    }
452    let max_gap_ns = max_gap_secs as i64 * 1_000_000_000;
453
454    state
455        .storage
456        .query_time_in_tool(query.start_time, query.end_time, max_gap_ns)
457        .await
458        .map(Json)
459        .map_err(|e| {
460            (
461                StatusCode::INTERNAL_SERVER_ERROR,
462                Json(ErrorResponse::storage_error(format!(
463                    "query time_in_tool: {e}"
464                ))),
465            )
466        })
467}
468
469/// Query parameters for top-spans endpoint
470#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
471pub struct TopSpansQuery {
472    /// Start time (nanoseconds since Unix epoch)
473    pub start_time: Option<i64>,
474    /// End time (nanoseconds since Unix epoch)
475    pub end_time: Option<i64>,
476    /// Maximum number of spans to return (default 20, capped at 100)
477    pub limit: Option<usize>,
478    /// Sort dimension: total_tokens (default), duration, output_input_ratio
479    /// (output divided by all input context), cache_efficiency
480    #[serde(default)]
481    pub sort_by: TopSpanSort,
482    /// When true, return only spans with finish_reason max_tokens or length
483    #[serde(default)]
484    pub truncated_only: bool,
485    /// Agent family filter: `claude`, `opencode`, or `codex`
486    pub agent: Option<String>,
487    /// Model name filter
488    pub model: Option<String>,
489    /// Provider filter
490    pub provider: Option<String>,
491    /// Project id filter (opencode data only)
492    pub project: Option<String>,
493    /// Session id filter
494    pub session: Option<String>,
495}
496
497/// Query parameters for top-sessions endpoint
498#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
499pub struct TopGroupQuery {
500    pub start_time: Option<i64>,
501    pub end_time: Option<i64>,
502    pub limit: Option<usize>,
503    /// Agent family filter: `claude`, `opencode`, or `codex`
504    pub agent: Option<String>,
505    /// Model name filter
506    pub model: Option<String>,
507    /// Provider filter
508    pub provider: Option<String>,
509    /// Project id filter (opencode data only)
510    pub project: Option<String>,
511    /// Session id filter
512    pub session: Option<String>,
513}
514
515/// Get the top-N LLM spans by the requested sort dimension
516#[utoipa::path(
517    get,
518    path = "/api/genai/top_spans",
519    params(TopSpansQuery),
520    responses(
521        (status = 200, description = "Top spans", body = GenAiItemsResponse),
522        (status = 500, description = "Internal server error", body = ErrorResponse)
523    ),
524    tag = "genai"
525)]
526pub async fn get_top_spans(
527    State(state): State<AppState>,
528    Query(query): Query<TopSpansQuery>,
529) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
530    let limit = query.limit.unwrap_or(20).clamp(1, 100);
531    let filters = query.filters();
532
533    let mut spans = state
534        .storage
535        .query_top_spans(
536            query.start_time,
537            query.end_time,
538            &filters,
539            limit,
540            query.sort_by,
541            query.truncated_only,
542        )
543        .await
544        .map_err(|e| {
545            (
546                StatusCode::INTERNAL_SERVER_ERROR,
547                Json(ErrorResponse::storage_error(format!(
548                    "query top spans: {}",
549                    e
550                ))),
551            )
552        })?;
553
554    let pricing = state.pricing.snapshot().await;
555    enrich_top_spans(&mut spans, &pricing.db);
556
557    Ok(Json(GenAiItemsResponse {
558        items: serde_json::to_value(&spans).map_err(|e| {
559            (
560                StatusCode::INTERNAL_SERVER_ERROR,
561                Json(ErrorResponse::storage_error(format!(
562                    "serialize top spans: {e}"
563                ))),
564            )
565        })?,
566        filters_applied: filters.applied(&FILTER_DIMENSIONS),
567    }))
568}
569
570fn enrich_session_rows(rows: &mut [SessionCostRow], db: &PricingDatabase) {
571    for row in rows {
572        let usage = TokenUsage {
573            input: row.input_tokens,
574            output: row.output_tokens,
575            ..Default::default()
576        };
577        let result = db.compute_cost(None, usage, None);
578        row.cost = result.cost;
579        row.cost_source = Some(result.source.as_str().to_string());
580    }
581}
582
583fn enrich_conversation_rows(rows: &mut [ConversationCostRow], db: &PricingDatabase) {
584    for row in rows {
585        let usage = TokenUsage {
586            input: row.input_tokens,
587            output: row.output_tokens,
588            ..Default::default()
589        };
590        let result = db.compute_cost(None, usage, None);
591        row.cost = result.cost;
592        row.cost_source = Some(result.source.as_str().to_string());
593    }
594}
595
596/// Get the top-N sessions by total token usage
597#[utoipa::path(
598    get,
599    path = "/api/genai/top_sessions",
600    params(TopGroupQuery),
601    responses(
602        (status = 200, description = "Top sessions", body = GenAiItemsResponse),
603        (status = 500, description = "Internal server error", body = ErrorResponse)
604    ),
605    tag = "genai"
606)]
607pub async fn get_top_sessions(
608    State(state): State<AppState>,
609    Query(query): Query<TopGroupQuery>,
610) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
611    let limit = query.limit.unwrap_or(20).clamp(1, 100);
612    let filters = query.filters();
613
614    let mut rows = state
615        .storage
616        .query_top_sessions(query.start_time, query.end_time, &filters, limit)
617        .await
618        .map_err(|e| {
619            (
620                StatusCode::INTERNAL_SERVER_ERROR,
621                Json(ErrorResponse::storage_error(format!(
622                    "query top sessions: {}",
623                    e
624                ))),
625            )
626        })?;
627
628    let pricing = state.pricing.snapshot().await;
629    enrich_session_rows(&mut rows, &pricing.db);
630
631    Ok(Json(GenAiItemsResponse {
632        items: serde_json::to_value(&rows).map_err(|e| {
633            (
634                StatusCode::INTERNAL_SERVER_ERROR,
635                Json(ErrorResponse::storage_error(format!(
636                    "serialize top sessions: {e}"
637                ))),
638            )
639        })?,
640        filters_applied: filters.applied(&FILTER_DIMENSIONS),
641    }))
642}
643
644/// Per-session context composition (#113, #244): fixed prompt prefix
645/// (minimum cache-read) vs peak context, plus the in-session growth.
646/// Sessions without any cache-read telemetry are omitted.
647#[utoipa::path(
648    get,
649    path = "/api/genai/context_composition",
650    params(TopGroupQuery),
651    responses(
652        (
653            status = 200,
654            description = "Sessions with context composition, fixed_prefix desc",
655            body = otelite_core::api::ContextCompositionResponse
656        ),
657        (status = 500, description = "Internal server error", body = ErrorResponse)
658    ),
659    tag = "genai"
660)]
661pub async fn get_context_composition(
662    State(state): State<AppState>,
663    Query(query): Query<TopGroupQuery>,
664) -> Result<Json<otelite_core::api::ContextCompositionResponse>, (StatusCode, Json<ErrorResponse>)>
665{
666    let limit = query.limit.unwrap_or(20).clamp(1, 100);
667    let filters = query.filters();
668
669    let mut sessions = state
670        .storage
671        .query_context_composition(query.start_time, query.end_time, &filters)
672        .await
673        .map_err(|e| {
674            (
675                StatusCode::INTERNAL_SERVER_ERROR,
676                Json(ErrorResponse::storage_error(format!(
677                    "query context composition: {}",
678                    e
679                ))),
680            )
681        })?;
682    // The query returns rows in (fixed_prefix desc) order; take the top N.
683    sessions.truncate(limit);
684
685    Ok(Json(otelite_core::api::ContextCompositionResponse {
686        sessions,
687    }))
688}
689
690/// Get the top-N conversations (gen_ai.conversation.id) by total token usage
691#[utoipa::path(
692    get,
693    path = "/api/genai/top_conversations",
694    params(TopGroupQuery),
695    responses(
696        (status = 200, description = "Top conversations", body = GenAiItemsResponse),
697        (status = 500, description = "Internal server error", body = ErrorResponse)
698    ),
699    tag = "genai"
700)]
701pub async fn get_top_conversations(
702    State(state): State<AppState>,
703    Query(query): Query<TopGroupQuery>,
704) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
705    let limit = query.limit.unwrap_or(20).clamp(1, 100);
706    let filters = query.filters();
707
708    let mut rows = state
709        .storage
710        .query_top_conversations(query.start_time, query.end_time, &filters, limit)
711        .await
712        .map_err(|e| {
713            (
714                StatusCode::INTERNAL_SERVER_ERROR,
715                Json(ErrorResponse::storage_error(format!(
716                    "query top conversations: {}",
717                    e
718                ))),
719            )
720        })?;
721
722    let pricing = state.pricing.snapshot().await;
723    enrich_conversation_rows(&mut rows, &pricing.db);
724
725    Ok(Json(GenAiItemsResponse {
726        items: serde_json::to_value(&rows).map_err(|e| {
727            (
728                StatusCode::INTERNAL_SERVER_ERROR,
729                Json(ErrorResponse::storage_error(format!(
730                    "serialize top conversations: {e}"
731                ))),
732            )
733        })?,
734        filters_applied: filters.applied(&FILTER_DIMENSIONS),
735    }))
736}
737
738/// Query parameters for finish-reason distribution endpoint
739#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
740pub struct FinishReasonsQuery {
741    /// Start time (nanoseconds since Unix epoch)
742    pub start_time: Option<i64>,
743    /// End time (nanoseconds since Unix epoch)
744    pub end_time: Option<i64>,
745    /// Agent family filter: `claude`, `opencode`, or `codex`
746    pub agent: Option<String>,
747    /// Model name filter
748    pub model: Option<String>,
749    /// Provider filter
750    pub provider: Option<String>,
751    /// Project id filter (opencode data only)
752    pub project: Option<String>,
753    /// Session id filter
754    pub session: Option<String>,
755}
756
757/// Get the distribution of finish / stop reasons across LLM spans
758///
759/// Combines OTel plural `gen_ai.response.finish_reasons`, singular `gen_ai.response.finish_reason`,
760/// and Claude Code `stop_reason` values from `claude_code.api_response_body` log bodies.
761#[utoipa::path(
762    get,
763    path = "/api/genai/finish_reasons",
764    params(FinishReasonsQuery),
765    responses(
766        (status = 200, description = "Finish reason counts", body = GenAiItemsResponse),
767        (status = 500, description = "Internal server error", body = ErrorResponse)
768    ),
769    tag = "genai"
770)]
771pub async fn get_finish_reasons(
772    State(state): State<AppState>,
773    Query(query): Query<FinishReasonsQuery>,
774) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
775    let filters = query.filters();
776
777    let rows = state
778        .storage
779        .query_finish_reasons(query.start_time, query.end_time, &filters)
780        .await
781        .map_err(|e| {
782            (
783                StatusCode::INTERNAL_SERVER_ERROR,
784                Json(ErrorResponse::storage_error(format!(
785                    "query finish reasons: {}",
786                    e
787                ))),
788            )
789        })?;
790
791    Ok(Json(GenAiItemsResponse {
792        items: serde_json::to_value(&rows).map_err(|e| {
793            (
794                StatusCode::INTERNAL_SERVER_ERROR,
795                Json(ErrorResponse::storage_error(format!(
796                    "serialize finish_reasons: {e}"
797                ))),
798            )
799        })?,
800        filters_applied: filters.applied(&FILTER_DIMENSIONS),
801    }))
802}
803
804/// Query parameters for latency endpoint
805#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
806pub struct LatencyQuery {
807    /// Start time (nanoseconds since Unix epoch)
808    pub start_time: Option<i64>,
809    /// End time (nanoseconds since Unix epoch)
810    pub end_time: Option<i64>,
811    /// Agent family filter: `claude`, `opencode`, or `codex`
812    pub agent: Option<String>,
813    /// Model name filter
814    pub model: Option<String>,
815    /// Provider filter
816    pub provider: Option<String>,
817    /// Project id filter (opencode data only)
818    pub project: Option<String>,
819    /// Session id filter
820    pub session: Option<String>,
821}
822
823/// Query parameters for the latency percentile endpoint.
824#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
825pub struct LatencyPercentileQuery {
826    /// Start time (nanoseconds since Unix epoch)
827    pub start_time: Option<i64>,
828    /// End time (nanoseconds since Unix epoch)
829    pub end_time: Option<i64>,
830    /// Bucket size in seconds (default 3600 = 1 hour). Ignored in
831    /// calendar-day mode.
832    pub bucket_secs: Option<u64>,
833    /// Comma-separated metrics: "duration,ttft" (default: both).
834    pub metrics: Option<String>,
835    /// Calendar-day bucketing: `1` (or `true`) buckets by local day in
836    /// `timezone` instead of the fixed `bucket_secs` grid. DST days are
837    /// 23 or 25 hours; empty days are present with null percentiles; calls
838    /// are attributed by start time. Requires explicit `start_time` and
839    /// `end_time` (#119/#141).
840    pub calendar_day: Option<String>,
841    /// IANA timezone for `calendar_day=1` (e.g. `Europe/London`,
842    /// `America/New_York`). Defaults to UTC. Ignored (and rejected)
843    /// without `calendar_day=1`.
844    pub timezone: Option<String>,
845    /// Agent family filter: `claude`, `opencode`, or `codex`
846    pub agent: Option<String>,
847    /// Model name filter
848    pub model: Option<String>,
849    /// Provider filter
850    pub provider: Option<String>,
851    /// Project id filter (opencode data only)
852    pub project: Option<String>,
853    /// Session id filter
854    pub session: Option<String>,
855}
856
857/// Get latency / TTFT percentile statistics per model for LLM spans.
858#[utoipa::path(
859    get,
860    path = "/api/genai/latency_stats",
861    params(LatencyQuery),
862    responses(
863        (status = 200, description = "Latency statistics per model", body = GenAiItemsResponse),
864        (status = 500, description = "Internal server error", body = ErrorResponse)
865    ),
866    tag = "genai"
867)]
868pub async fn get_latency_stats(
869    State(state): State<AppState>,
870    Query(query): Query<LatencyQuery>,
871) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
872    let filters = query.filters();
873
874    let rows = state
875        .storage
876        .query_latency_stats(query.start_time, query.end_time, &filters)
877        .await
878        .map_err(|e| {
879            (
880                StatusCode::INTERNAL_SERVER_ERROR,
881                Json(ErrorResponse::storage_error(format!(
882                    "query latency stats: {}",
883                    e
884                ))),
885            )
886        })?;
887
888    Ok(Json(GenAiItemsResponse {
889        items: serde_json::to_value(&rows).map_err(|e| {
890            (
891                StatusCode::INTERNAL_SERVER_ERROR,
892                Json(ErrorResponse::storage_error(format!(
893                    "serialize latency_stats: {e}"
894                ))),
895            )
896        })?,
897        filters_applied: filters.applied(&FILTER_DIMENSIONS),
898    }))
899}
900
901/// Bucketed p50/p90/p95/p99 latency percentiles by model (issue #132).
902/// Duration comes from LLM request spans (all harnesses); TTFT from the
903/// spans' normalized TTFT attribute plus the codex turn TTFT histogram
904/// (disjoint cohort — codex request spans carry no TTFT attribute).
905#[utoipa::path(
906    get,
907    path = "/api/genai/latency_percentiles",
908    params(LatencyPercentileQuery),
909    responses(
910        (status = 200, description = "Percentile series per metric and model", body = LatencyPercentilesResponse),
911        (status = 400, description = "Invalid bucket_secs or metrics", body = ErrorResponse),
912        (status = 500, description = "Internal server error", body = ErrorResponse)
913    ),
914    tag = "genai"
915)]
916pub async fn get_latency_percentiles(
917    State(state): State<AppState>,
918    Query(query): Query<LatencyPercentileQuery>,
919) -> Result<Json<LatencyPercentilesResponse>, (StatusCode, Json<ErrorResponse>)> {
920    let filters = query.filters();
921    let bucket_secs = query.bucket_secs.unwrap_or(3600);
922    if bucket_secs == 0 {
923        return Err((
924            StatusCode::BAD_REQUEST,
925            Json(ErrorResponse::bad_request(
926                "bucket_secs must be a positive number of seconds",
927            )),
928        ));
929    }
930    let metrics: Vec<&str> = query
931        .metrics
932        .as_deref()
933        .unwrap_or("duration,ttft")
934        .split(',')
935        .map(|s| s.trim())
936        .filter(|s| !s.is_empty())
937        .collect();
938    for m in &metrics {
939        if *m != "duration" && *m != "ttft" {
940            return Err((
941                StatusCode::BAD_REQUEST,
942                Json(ErrorResponse::bad_request(format!(
943                    "unknown metric '{m}' — expected \"duration\" and/or \"ttft\""
944                ))),
945            ));
946        }
947    }
948    if metrics.is_empty() {
949        return Err((
950            StatusCode::BAD_REQUEST,
951            Json(ErrorResponse::bad_request(
952                "metrics must include \"duration\" and/or \"ttft\"",
953            )),
954        ));
955    }
956
957    // Calendar-day mode (#141): explicit, never a silent switch. The
958    // timezone must validate as an IANA zone; the window must be explicit.
959    let calendar_day = match query.calendar_day.as_deref() {
960        None => false,
961        Some("1" | "true" | "yes") => true,
962        Some("0" | "false" | "no") => false,
963        Some(v) => {
964            return Err((
965                StatusCode::BAD_REQUEST,
966                Json(ErrorResponse::bad_request(format!(
967                    "calendar_day must be 1/0, true/false or yes/no, got '{v}'"
968                ))),
969            ))
970        },
971    };
972    let timezone: Option<String> = match (query.timezone.as_deref(), calendar_day) {
973        (Some(tz), true) => {
974            let tz = tz.trim();
975            let _parsed: chrono_tz::Tz = std::str::FromStr::from_str(tz).map_err(|e| {
976                (
977                    StatusCode::BAD_REQUEST,
978                    Json(ErrorResponse::bad_request(format!(
979                        "unknown IANA timezone '{tz}': {e} (e.g. Europe/London, America/New_York, UTC)"
980                    ))),
981                )
982            })?;
983            Some(tz.to_string())
984        },
985        (None, true) => Some("UTC".to_string()),
986        (Some(tz), false) => {
987            return Err((
988                StatusCode::BAD_REQUEST,
989                Json(ErrorResponse::bad_request(format!(
990                    "timezone '{tz}' is only used with calendar_day=1"
991                ))),
992            ))
993        },
994        (None, false) => None,
995    };
996    if calendar_day && (query.start_time.is_none() || query.end_time.is_none()) {
997        return Err((
998            StatusCode::BAD_REQUEST,
999            Json(ErrorResponse::bad_request(
1000                "calendar_day=1 requires explicit start_time and end_time",
1001            )),
1002        ));
1003    }
1004    let timezone = timezone.as_deref();
1005
1006    let mut rows = state
1007        .storage
1008        .query_latency_percentiles(
1009            query.start_time,
1010            query.end_time,
1011            &filters,
1012            bucket_secs,
1013            &metrics,
1014            timezone,
1015        )
1016        .await
1017        .map_err(|e| {
1018            (
1019                StatusCode::INTERNAL_SERVER_ERROR,
1020                Json(ErrorResponse::storage_error(format!(
1021                    "query latency percentiles: {}",
1022                    e
1023                ))),
1024            )
1025        })?;
1026
1027    rows.filters_applied = filters.applied(&FILTER_DIMENSIONS);
1028
1029    Ok(Json(rows))
1030}
1031
1032/// Query parameters for the generic distribution endpoint.
1033#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1034pub struct DistributionQuery {
1035    /// Metric cohort: session_cost | tool_duration | llm_duration | ttft | output_tokens.
1036    pub metric: String,
1037    /// Start time (nanoseconds since Unix epoch)
1038    pub start_time: Option<i64>,
1039    /// End time (nanoseconds since Unix epoch)
1040    pub end_time: Option<i64>,
1041    /// Number of buckets (default 20, cap 100).
1042    pub buckets: Option<usize>,
1043    /// Binning scale: linear | log (default linear).
1044    pub scale: Option<String>,
1045    /// Agent family filter: `claude`, `opencode`, or `codex`
1046    pub agent: Option<String>,
1047    /// Model name filter
1048    pub model: Option<String>,
1049    /// Provider filter
1050    pub provider: Option<String>,
1051    /// Project id filter (opencode data only)
1052    pub project: Option<String>,
1053    /// Session id filter
1054    pub session: Option<String>,
1055}
1056
1057/// Generic distribution over a named metric cohort (issue #133). One
1058/// endpoint serving chart, table and CLI from the same JSON.
1059/// `session_cost` is resolved here (claude sessions are priced from tokens);
1060/// the span/metric cohorts are resolved in the storage layer.
1061#[utoipa::path(
1062    get,
1063    path = "/api/genai/distributions",
1064    params(DistributionQuery),
1065    responses(
1066        (status = 200, description = "Distribution buckets + summary stats", body = otelite_core::api::DistributionResponse),
1067        (status = 400, description = "Invalid metric, scale or buckets", body = ErrorResponse),
1068        (status = 500, description = "Internal server error", body = ErrorResponse)
1069    ),
1070    tag = "genai"
1071)]
1072pub async fn get_distributions(
1073    State(state): State<AppState>,
1074    Query(query): Query<DistributionQuery>,
1075) -> Result<Json<otelite_core::api::DistributionResponse>, (StatusCode, Json<ErrorResponse>)> {
1076    use otelite_core::distribution;
1077    use otelite_core::session_cost;
1078
1079    const KNOWN: &[&str] = &[
1080        "session_cost",
1081        "tool_duration",
1082        "llm_duration",
1083        "ttft",
1084        "output_tokens",
1085    ];
1086    if !KNOWN.contains(&query.metric.as_str()) {
1087        return Err((
1088            StatusCode::BAD_REQUEST,
1089            Json(ErrorResponse::bad_request(format!(
1090                "unknown metric '{}' — expected one of: {}",
1091                query.metric,
1092                KNOWN.join(" | ")
1093            ))),
1094        ));
1095    }
1096    let scale = query.scale.as_deref().unwrap_or("linear");
1097    if scale != "linear" && scale != "log" {
1098        return Err((
1099            StatusCode::BAD_REQUEST,
1100            Json(ErrorResponse::bad_request(format!(
1101                "unknown scale '{scale}' — expected \"linear\" or \"log\""
1102            ))),
1103        ));
1104    }
1105    let buckets = query.buckets.unwrap_or(20);
1106    if buckets == 0 {
1107        return Err((
1108            StatusCode::BAD_REQUEST,
1109            Json(ErrorResponse::bad_request(
1110                "buckets must be a positive integer",
1111            )),
1112        ));
1113    }
1114
1115    let filters = query.filters();
1116
1117    let resp = if query.metric == "session_cost" {
1118        // Same source as the sessions cost panel (#126): opencode's own cost
1119        // counter ("actual") plus claude sessions priced from tokens.
1120        let rows = state
1121            .storage
1122            .query_session_costs(query.start_time, query.end_time)
1123            .await
1124            .map_err(|e| {
1125                (
1126                    StatusCode::INTERNAL_SERVER_ERROR,
1127                    Json(ErrorResponse::storage_error(format!(
1128                        "query session costs: {e}"
1129                    ))),
1130                )
1131            })?;
1132        let pricing = state.pricing.snapshot().await;
1133        let sessions = session_cost::build_session_costs(rows, &pricing.db);
1134        let values: Vec<f64> = sessions.iter().filter_map(|s| s.cost_usd).collect();
1135        distribution::build("session_cost", "usd", scale, buckets, values)
1136    } else {
1137        state
1138            .storage
1139            .query_distribution(
1140                &query.metric,
1141                query.start_time,
1142                query.end_time,
1143                &filters,
1144                buckets,
1145                scale,
1146            )
1147            .await
1148            .map_err(|e| {
1149                (
1150                    StatusCode::INTERNAL_SERVER_ERROR,
1151                    Json(ErrorResponse::storage_error(format!(
1152                        "query distribution: {e}"
1153                    ))),
1154                )
1155            })?
1156    };
1157
1158    Ok(Json(resp))
1159}
1160
1161/// Get native GenAI telemetry capability coverage and provenance.
1162#[utoipa::path(
1163    get,
1164    path = "/api/genai/capabilities",
1165    params(ModelAnalyticsQuery),
1166    responses(
1167        (status = 200, description = "GenAI telemetry capability report", body = GenAiCapabilityResponse),
1168        (status = 500, description = "Internal server error", body = ErrorResponse)
1169    ),
1170    tag = "genai"
1171)]
1172pub async fn get_genai_capabilities(
1173    State(state): State<AppState>,
1174    Query(query): Query<ModelAnalyticsQuery>,
1175) -> Result<Json<GenAiCapabilityResponse>, (StatusCode, Json<ErrorResponse>)> {
1176    let filters = query.filters();
1177    let mut report = state
1178        .storage
1179        .query_genai_capabilities(query.start_time, query.end_time, &filters)
1180        .await
1181        .map_err(|e| {
1182            (
1183                StatusCode::INTERNAL_SERVER_ERROR,
1184                Json(ErrorResponse::storage_error(format!(
1185                    "query GenAI capabilities: {e}"
1186                ))),
1187            )
1188        })?;
1189    report.filters_applied = filters.applied(&FILTER_DIMENSIONS);
1190
1191    Ok(Json(report))
1192}
1193
1194/// Query parameters for the model-performance diagnosis endpoint
1195/// (#121/#153). The current interval is explicit and required; the rolling
1196/// baseline is a length, placed immediately before the derived preceding
1197/// window so it can never overlap either comparison window.
1198#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1199pub struct ModelPerformanceQueryParams {
1200    /// Start of the current interval (nanoseconds since Unix epoch)
1201    pub start_time: i64,
1202    /// End of the current interval (nanoseconds since Unix epoch)
1203    pub end_time: i64,
1204    /// Rolling baseline length in nanoseconds; the baseline sits immediately
1205    /// before the derived preceding window. Omit to disable it.
1206    pub rolling_ns: Option<i64>,
1207    /// Model name filter
1208    pub model: Option<String>,
1209    /// Provider filter
1210    pub provider: Option<String>,
1211    /// IANA timezone echoed back for calendar alignment (e.g. Europe/London)
1212    pub timezone: Option<String>,
1213}
1214
1215/// Get the model-performance diagnosis: the canonical comparison of a
1216/// current interval against its preceding interval and an optional rolling
1217/// baseline, with the deterministic classification and confidence for each
1218/// identity (#121).
1219#[utoipa::path(
1220    get,
1221    path = "/api/genai/model-performance",
1222    params(ModelPerformanceQueryParams),
1223    responses(
1224        (status = 200, description = "Model-performance diagnosis", body = otelite_core::api::ModelPerformanceDiagnosis),
1225        (status = 400, description = "Invalid interval or baseline", body = ErrorResponse),
1226        (status = 500, description = "Internal server error", body = ErrorResponse)
1227    ),
1228    tag = "genai"
1229)]
1230pub async fn get_model_performance(
1231    State(state): State<AppState>,
1232    Query(query): Query<ModelPerformanceQueryParams>,
1233) -> Result<Json<otelite_core::api::ModelPerformanceDiagnosis>, (StatusCode, Json<ErrorResponse>)> {
1234    if query.end_time <= query.start_time {
1235        return Err((
1236            StatusCode::BAD_REQUEST,
1237            Json(ErrorResponse::bad_request(format!(
1238                "end_time must be after start_time (got start={} end={})",
1239                query.start_time, query.end_time
1240            ))),
1241        ));
1242    }
1243    if let Some(len) = query.rolling_ns {
1244        if len <= 0 {
1245            return Err((
1246                StatusCode::BAD_REQUEST,
1247                Json(ErrorResponse::bad_request(format!(
1248                    "rolling_ns must be a positive length in nanoseconds (got {len})"
1249                ))),
1250            ));
1251        }
1252    }
1253    if let Some(tz) = query.timezone.as_deref() {
1254        let tz = tz.trim();
1255        let _parsed: chrono_tz::Tz = std::str::FromStr::from_str(tz).map_err(|e| {
1256            (
1257                StatusCode::BAD_REQUEST,
1258                Json(ErrorResponse::bad_request(format!(
1259                    "unknown IANA timezone '{tz}': {e} (e.g. Europe/London, America/New_York, UTC)"
1260                ))),
1261            )
1262        })?;
1263    }
1264
1265    // Preceding window: equal length immediately before the current one.
1266    // Rolling baseline: the requested length immediately before the
1267    // preceding window — structurally excluding both comparison windows.
1268    let current_len = query.end_time - query.start_time;
1269    let preceding_start = query.start_time - current_len;
1270    let query_core = otelite_core::api::ModelPerformanceQuery {
1271        current: otelite_core::api::ModelPerformanceWindow {
1272            start_time: query.start_time,
1273            end_time: query.end_time,
1274        },
1275        rolling: query
1276            .rolling_ns
1277            .map(|len| otelite_core::api::ModelPerformanceWindow {
1278                start_time: preceding_start - len,
1279                end_time: preceding_start,
1280            }),
1281        model: query.model.clone(),
1282        provider: query.provider.clone(),
1283    };
1284
1285    let response = state
1286        .storage
1287        .query_model_performance(&query_core)
1288        .await
1289        .map_err(|e| {
1290            (
1291                StatusCode::INTERNAL_SERVER_ERROR,
1292                Json(ErrorResponse::storage_error(format!(
1293                    "query model performance: {e}"
1294                ))),
1295            )
1296        })?;
1297    let capability = state
1298        .storage
1299        .query_genai_capabilities(
1300            Some(query_core.current.start_time),
1301            Some(query_core.current.end_time),
1302            &GenAiFilters {
1303                model: query.model.clone(),
1304                provider: query.provider.clone(),
1305                ..Default::default()
1306            },
1307        )
1308        .await
1309        .map_err(|e| {
1310            (
1311                StatusCode::INTERNAL_SERVER_ERROR,
1312                Json(ErrorResponse::storage_error(format!(
1313                    "query GenAI capabilities for TTFT trust: {e}"
1314                ))),
1315            )
1316        })?;
1317
1318    Ok(Json(otelite_core::model_performance::build_diagnosis(
1319        &response,
1320        &capability,
1321        query.timezone.clone(),
1322    )))
1323}
1324
1325/// Query parameters for error-rate endpoint
1326#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1327pub struct ErrorRateQuery {
1328    /// Start time (nanoseconds since Unix epoch)
1329    pub start_time: Option<i64>,
1330    /// End time (nanoseconds since Unix epoch)
1331    pub end_time: Option<i64>,
1332    /// Agent family filter: `claude`, `opencode`, or `codex`
1333    pub agent: Option<String>,
1334    /// Model name filter
1335    pub model: Option<String>,
1336    /// Provider filter
1337    pub provider: Option<String>,
1338    /// Project id filter (opencode data only)
1339    pub project: Option<String>,
1340    /// Session id filter
1341    pub session: Option<String>,
1342}
1343
1344/// Get error rate per model across LLM spans.
1345#[utoipa::path(
1346    get,
1347    path = "/api/genai/error_rate",
1348    params(ErrorRateQuery),
1349    responses(
1350        (status = 200, description = "Error rate per model", body = GenAiItemsResponse),
1351        (status = 500, description = "Internal server error", body = ErrorResponse)
1352    ),
1353    tag = "genai"
1354)]
1355pub async fn get_error_rate(
1356    State(state): State<AppState>,
1357    Query(query): Query<ErrorRateQuery>,
1358) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
1359    let filters = query.filters();
1360
1361    let rows = state
1362        .storage
1363        .query_error_rate(query.start_time, query.end_time, &filters)
1364        .await
1365        .map_err(|e| {
1366            (
1367                StatusCode::INTERNAL_SERVER_ERROR,
1368                Json(ErrorResponse::storage_error(format!(
1369                    "query error rate: {}",
1370                    e
1371                ))),
1372            )
1373        })?;
1374
1375    Ok(Json(GenAiItemsResponse {
1376        items: serde_json::to_value(&rows).map_err(|e| {
1377            (
1378                StatusCode::INTERNAL_SERVER_ERROR,
1379                Json(ErrorResponse::storage_error(format!(
1380                    "serialize error_rate: {e}"
1381                ))),
1382            )
1383        })?,
1384        filters_applied: filters.applied(&FILTER_DIMENSIONS),
1385    }))
1386}
1387
1388/// Query parameters for tool-usage endpoint
1389#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1390pub struct ToolUsageQuery {
1391    /// Start time (nanoseconds since Unix epoch)
1392    pub start_time: Option<i64>,
1393    /// End time (nanoseconds since Unix epoch)
1394    pub end_time: Option<i64>,
1395    /// Maximum number of tools to return (default 20, capped at 100)
1396    pub limit: Option<usize>,
1397    /// Agent family filter: `claude`, `opencode`, or `codex`
1398    pub agent: Option<String>,
1399    /// Model name filter
1400    pub model: Option<String>,
1401    /// Provider filter
1402    pub provider: Option<String>,
1403    /// Project id filter (opencode data only)
1404    pub project: Option<String>,
1405    /// Session id filter
1406    pub session: Option<String>,
1407}
1408
1409/// Get aggregated per-tool usage for tool-execution spans.
1410#[utoipa::path(
1411    get,
1412    path = "/api/genai/tool_usage",
1413    params(ToolUsageQuery),
1414    responses(
1415        (status = 200, description = "Tool usage aggregates", body = GenAiItemsResponse),
1416        (status = 500, description = "Internal server error", body = ErrorResponse)
1417    ),
1418    tag = "genai"
1419)]
1420pub async fn get_tool_usage(
1421    State(state): State<AppState>,
1422    Query(query): Query<ToolUsageQuery>,
1423) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
1424    let limit = query.limit.unwrap_or(20).clamp(1, 100);
1425    let filters = query.filters();
1426    let rows = state
1427        .storage
1428        .query_tool_usage(query.start_time, query.end_time, &filters, limit)
1429        .await
1430        .map_err(|e| {
1431            (
1432                StatusCode::INTERNAL_SERVER_ERROR,
1433                Json(ErrorResponse::storage_error(format!(
1434                    "query tool usage: {}",
1435                    e
1436                ))),
1437            )
1438        })?;
1439
1440    Ok(Json(GenAiItemsResponse {
1441        items: serde_json::to_value(&rows).map_err(|e| {
1442            (
1443                StatusCode::INTERNAL_SERVER_ERROR,
1444                Json(ErrorResponse::storage_error(format!(
1445                    "serialize tool_usage: {e}"
1446                ))),
1447            )
1448        })?,
1449        filters_applied: filters.applied(&FILTER_DIMENSIONS),
1450    }))
1451}
1452
1453/// Query parameters for the recent-retries endpoint
1454#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1455pub struct RecentRetriesQuery {
1456    /// Start time (nanoseconds since Unix epoch)
1457    pub start_time: Option<i64>,
1458    /// End time (nanoseconds since Unix epoch)
1459    pub end_time: Option<i64>,
1460    /// Maximum number of incidents to return (default 20, capped at 100)
1461    pub limit: Option<usize>,
1462    /// Agent family filter: `claude`, `opencode`, or `codex`
1463    pub agent: Option<String>,
1464    /// Model name filter
1465    pub model: Option<String>,
1466    /// Provider filter
1467    pub provider: Option<String>,
1468    /// Project id filter (opencode data only)
1469    pub project: Option<String>,
1470    /// Session id filter
1471    pub session: Option<String>,
1472}
1473
1474/// Get recent retry incidents: LLM calls whose final attempt marker is > 1,
1475/// newest first. The one-call answer to "when did my streaming retries
1476/// happen" — previously required bisecting retry_stats and scanning traces.
1477#[utoipa::path(
1478    get,
1479    path = "/api/genai/retries",
1480    params(RecentRetriesQuery),
1481    responses(
1482        (status = 200, description = "Retried LLM calls, newest first", body = GenAiItemsResponse),
1483        (status = 500, description = "Internal server error", body = ErrorResponse)
1484    ),
1485    tag = "genai"
1486)]
1487pub async fn get_recent_retries(
1488    State(state): State<AppState>,
1489    Query(query): Query<RecentRetriesQuery>,
1490) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
1491    let limit = query.limit.unwrap_or(20).clamp(1, 100);
1492    let filters = query.filters();
1493
1494    let rows = state
1495        .storage
1496        .query_recent_retries(query.start_time, query.end_time, &filters, limit)
1497        .await
1498        .map_err(|e| {
1499            (
1500                StatusCode::INTERNAL_SERVER_ERROR,
1501                Json(ErrorResponse::storage_error(format!(
1502                    "query recent retries: {}",
1503                    e
1504                ))),
1505            )
1506        })?;
1507
1508    Ok(Json(GenAiItemsResponse {
1509        items: serde_json::to_value(&rows).map_err(|e| {
1510            (
1511                StatusCode::INTERNAL_SERVER_ERROR,
1512                Json(ErrorResponse::storage_error(format!(
1513                    "serialize recent retries: {e}"
1514                ))),
1515            )
1516        })?,
1517        filters_applied: filters.applied(&FILTER_DIMENSIONS),
1518    }))
1519}
1520
1521/// Query parameters for retry-stats endpoint
1522#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1523pub struct RetryStatsQuery {
1524    /// Start time (nanoseconds since Unix epoch)
1525    pub start_time: Option<i64>,
1526    /// End time (nanoseconds since Unix epoch)
1527    pub end_time: Option<i64>,
1528    /// Agent family filter: `claude`, `opencode`, or `codex`
1529    pub agent: Option<String>,
1530    /// Model name filter
1531    pub model: Option<String>,
1532    /// Provider filter
1533    pub provider: Option<String>,
1534    /// Project id filter (opencode data only)
1535    pub project: Option<String>,
1536    /// Session id filter
1537    pub session: Option<String>,
1538}
1539
1540/// Get retry statistics across LLM spans.
1541#[utoipa::path(
1542    get,
1543    path = "/api/genai/retry_stats",
1544    params(RetryStatsQuery),
1545    responses(
1546        (status = 200, description = "Retry statistics", body = GenAiItemsResponse),
1547        (status = 500, description = "Internal server error", body = ErrorResponse)
1548    ),
1549    tag = "genai"
1550)]
1551pub async fn get_retry_stats(
1552    State(state): State<AppState>,
1553    Query(query): Query<RetryStatsQuery>,
1554) -> Result<Json<RetryStats>, (StatusCode, Json<ErrorResponse>)> {
1555    let filters = query.filters();
1556    let mut stats = state
1557        .storage
1558        .query_retry_stats(query.start_time, query.end_time, &filters)
1559        .await
1560        .map_err(|e| {
1561            (
1562                StatusCode::INTERNAL_SERVER_ERROR,
1563                Json(ErrorResponse::storage_error(format!(
1564                    "query retry stats: {}",
1565                    e
1566                ))),
1567            )
1568        })?;
1569
1570    stats.filters_applied = filters.applied(&FILTER_DIMENSIONS);
1571
1572    Ok(Json(stats))
1573}
1574
1575/// Query parameters for retrieval-stats endpoint
1576#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1577pub struct RetrievalStatsQuery {
1578    /// Start time (nanoseconds since Unix epoch)
1579    pub start_time: Option<i64>,
1580    /// End time (nanoseconds since Unix epoch)
1581    pub end_time: Option<i64>,
1582    /// Maximum number of top queries to return (default 5, capped at 20)
1583    pub limit: Option<usize>,
1584    /// Agent family filter: `claude`, `opencode`, or `codex`
1585    pub agent: Option<String>,
1586    /// Model name filter
1587    pub model: Option<String>,
1588    /// Provider filter
1589    pub provider: Option<String>,
1590    /// Project id filter (opencode data only)
1591    pub project: Option<String>,
1592    /// Session id filter
1593    pub session: Option<String>,
1594}
1595
1596/// Get aggregated retrieval / RAG statistics across retriever spans.
1597///
1598/// Retriever spans are identified by `openinference.span.kind = 'RETRIEVER'` or
1599/// the presence of a `retrieval.query` attribute. Returns total counts, average
1600/// documents per query, average top-1 document score, and the top-N most-frequent
1601/// queries.
1602#[utoipa::path(
1603    get,
1604    path = "/api/genai/retrieval_stats",
1605    params(RetrievalStatsQuery),
1606    responses(
1607        (status = 200, description = "Retrieval statistics", body = RetrievalStats),
1608        (status = 500, description = "Internal server error", body = ErrorResponse)
1609    ),
1610    tag = "genai"
1611)]
1612pub async fn get_retrieval_stats(
1613    State(state): State<AppState>,
1614    Query(query): Query<RetrievalStatsQuery>,
1615) -> Result<Json<RetrievalStats>, (StatusCode, Json<ErrorResponse>)> {
1616    let filters = query.filters();
1617    let limit = query.limit.unwrap_or(5).clamp(1, 20);
1618
1619    let mut stats = state
1620        .storage
1621        .query_retrieval_stats(query.start_time, query.end_time, &filters, limit)
1622        .await
1623        .map_err(|e| {
1624            (
1625                StatusCode::INTERNAL_SERVER_ERROR,
1626                Json(ErrorResponse::storage_error(format!(
1627                    "query retrieval stats: {}",
1628                    e
1629                ))),
1630            )
1631        })?;
1632
1633    stats.filters_applied = filters.applied(&FILTER_DIMENSIONS);
1634
1635    Ok(Json(stats))
1636}
1637
1638/// Metadata about the pricing database currently in use by the server.
1639#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
1640pub struct PricingMetadata {
1641    /// "litellm" when the upstream LiteLLM fetch has succeeded at least once;
1642    /// "fallback" when only the hardcoded Claude 4.x table is available.
1643    pub source: &'static str,
1644    /// Number of entries in the active pricing database (0 for fallback-only).
1645    pub entry_count: usize,
1646    /// Unix milliseconds of the last successful LiteLLM fetch, if any.
1647    pub last_fetched_unix_ms: Option<i64>,
1648    /// Unix milliseconds of the last failed LiteLLM fetch, if any.
1649    pub last_failed_unix_ms: Option<i64>,
1650    /// Date the hardcoded Claude 4.x fallback table was last verified against
1651    /// Anthropic's list rates.
1652    pub fallback_last_verified: &'static str,
1653    /// URL to the LiteLLM source file for attribution / deep-linking.
1654    pub source_url: &'static str,
1655    /// MIT-license acknowledgement for the LiteLLM data.
1656    pub license: &'static str,
1657    /// User-facing disclaimer text — safe to render inline.
1658    pub disclaimer: &'static str,
1659    /// Filter dimensions the endpoint actually applied (global filter bar, #135)
1660    pub filters_applied: Vec<String>,
1661}
1662
1663/// Return the list of agent-framework recognizers (CrewAI, AutoGen, LangGraph).
1664/// The web UI and any other client consumes this to know which attributes to
1665/// group under each framework section — keeps the vocabulary in one place.
1666#[utoipa::path(
1667    get,
1668    path = "/api/genai/agent_framework_defs",
1669    responses(
1670        (status = 200, description = "Agent framework recognizers"),
1671    ),
1672    tag = "genai"
1673)]
1674pub async fn get_agent_framework_defs(
1675) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
1676    let items =
1677        serde_json::to_value(otelite_core::agent_frameworks::AGENT_FRAMEWORKS).map_err(|e| {
1678            (
1679                StatusCode::INTERNAL_SERVER_ERROR,
1680                Json(ErrorResponse::storage_error(format!(
1681                    "serialize agent framework defs: {e}"
1682                ))),
1683            )
1684        })?;
1685    Ok(Json(GenAiItemsResponse {
1686        items,
1687        // Static reference data — the bar's dimensions don't apply to it.
1688        filters_applied: Vec::new(),
1689    }))
1690}
1691
1692const PRICING_DISCLAIMER: &str =
1693    "Cost figures are best-effort estimates. Per-token rates sourced from the LiteLLM \
1694     community pricing database (MIT-licensed, © 2023 Berri AI). When the upstream \
1695     fetch is unavailable, a small hand-curated Claude 4.x fallback table is used.";
1696
1697/// Return metadata describing which pricing database the server is currently
1698/// using. The frontend reads this once to render the disclaimer banner and a
1699/// source/freshness badge.
1700#[utoipa::path(
1701    get,
1702    path = "/api/genai/pricing_metadata",
1703    responses(
1704        (status = 200, description = "Pricing metadata", body = PricingMetadata),
1705    ),
1706    tag = "genai"
1707)]
1708pub async fn get_pricing_metadata(State(state): State<AppState>) -> Json<PricingMetadata> {
1709    let snapshot = state.pricing.snapshot().await;
1710    Json(PricingMetadata {
1711        source: if snapshot.db.is_litellm() {
1712            "litellm"
1713        } else {
1714            "fallback"
1715        },
1716        entry_count: snapshot.db.len(),
1717        last_fetched_unix_ms: snapshot.last_fetched_unix_ms,
1718        last_failed_unix_ms: snapshot.last_failed_unix_ms,
1719        fallback_last_verified: otelite_core::pricing::FALLBACK_LAST_VERIFIED,
1720        source_url: otelite_core::pricing::LITELLM_SOURCE_URL,
1721        license: otelite_core::pricing::LITELLM_LICENSE,
1722        disclaimer: PRICING_DISCLAIMER,
1723        filters_applied: Vec::new(),
1724    })
1725}
1726
1727/// Query parameters shared by the new per-model analytics endpoints.
1728#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1729pub struct ModelAnalyticsQuery {
1730    pub start_time: Option<i64>,
1731    pub end_time: Option<i64>,
1732    /// Agent family filter: `claude`, `opencode`, or `codex`
1733    pub agent: Option<String>,
1734    /// Model name filter
1735    pub model: Option<String>,
1736    /// Provider filter
1737    pub provider: Option<String>,
1738    /// Project id filter (opencode data only)
1739    pub project: Option<String>,
1740    /// Session id filter
1741    pub session: Option<String>,
1742}
1743
1744/// Query parameters for the cache hit rate / cache economics endpoint.
1745#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1746pub struct CacheQuery {
1747    pub start_time: Option<i64>,
1748    pub end_time: Option<i64>,
1749    // Filter dimensions: model only applies without `by_model`; the economics
1750    // payload is always per-model.
1751    /// Agent family filter: `claude`, `opencode`, or `codex`
1752    pub agent: Option<String>,
1753    /// Model name filter
1754    pub model: Option<String>,
1755    /// Provider filter
1756    pub provider: Option<String>,
1757    /// Project id filter (opencode data only)
1758    pub project: Option<String>,
1759    /// Session id filter
1760    pub session: Option<String>,
1761    /// Pass `1` (or `true`) to return the cache-economics payload
1762    /// (per-model read/write split, hit rate, read:write ratio, estimated
1763    /// savings, plus a time-bucketed series). Without it, the original
1764    /// per-model hit-rate list is returned unchanged.
1765    pub by_model: Option<String>,
1766    /// Bucket size in seconds for the economics series (default 3600).
1767    /// Only used with `by_model=1`.
1768    pub bucket_secs: Option<u64>,
1769}
1770
1771/// Query parameters for time-series endpoints.
1772#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1773pub struct TimeSeriesQuery {
1774    pub start_time: Option<i64>,
1775    pub end_time: Option<i64>,
1776    /// Bucket size in seconds (default 3600 = 1 hour).
1777    pub bucket_secs: Option<u64>,
1778    /// Span filter: "llm" (default) = LLM spans only; "all" = all OTel spans grouped by name.
1779    pub span_filter: Option<String>,
1780    /// Agent family filter: `claude`, `opencode`, or `codex`
1781    pub agent: Option<String>,
1782    /// Model name filter
1783    pub model: Option<String>,
1784    /// Provider filter
1785    pub provider: Option<String>,
1786    /// Project id filter (opencode data only)
1787    pub project: Option<String>,
1788    /// Session id filter
1789    pub session: Option<String>,
1790}
1791
1792/// Query parameters for time-series endpoints that also accept a model filter.
1793#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1794pub struct ModelTimeSeriesQuery {
1795    pub start_time: Option<i64>,
1796    pub end_time: Option<i64>,
1797    /// Bucket size in seconds (default 3600 = 1 hour).
1798    pub bucket_secs: Option<u64>,
1799    /// Optional model filter (e.g. "claude-opus-4-7").
1800    /// Agent family filter: `claude`, `opencode`, or `codex`
1801    pub agent: Option<String>,
1802    /// Model name filter
1803    pub model: Option<String>,
1804    /// Provider filter
1805    pub provider: Option<String>,
1806    /// Project id filter (opencode data only)
1807    pub project: Option<String>,
1808    /// Session id filter
1809    pub session: Option<String>,
1810    /// Span filter: "llm" (default) = LLM spans only; "all" = all OTel spans grouped by name.
1811    pub span_filter: Option<String>,
1812}
1813
1814/// Query parameters for endpoints that only filter by time.
1815#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1816pub struct TimeRangeQuery {
1817    pub start_time: Option<i64>,
1818    pub end_time: Option<i64>,
1819    /// Agent family filter: `claude`, `opencode`, or `codex`
1820    pub agent: Option<String>,
1821    /// Model name filter
1822    pub model: Option<String>,
1823    /// Provider filter
1824    pub provider: Option<String>,
1825    /// Project id filter (opencode data only)
1826    pub project: Option<String>,
1827    /// Session id filter
1828    pub session: Option<String>,
1829}
1830
1831/// Truncation rate (finish_reason = max_tokens / length) per model.
1832#[utoipa::path(
1833    get,
1834    path = "/api/genai/truncation_rate",
1835    params(ModelAnalyticsQuery),
1836    responses(
1837        (status = 200, description = "Truncation rate by model", body = GenAiItemsResponse),
1838        (status = 500, description = "Internal server error", body = ErrorResponse)
1839    ),
1840    tag = "genai"
1841)]
1842pub async fn get_truncation_rate(
1843    State(state): State<AppState>,
1844    Query(query): Query<ModelAnalyticsQuery>,
1845) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
1846    let filters = query.filters();
1847
1848    let rows = state
1849        .storage
1850        .query_truncation_rate(query.start_time, query.end_time, &filters)
1851        .await
1852        .map_err(|e| {
1853            (
1854                StatusCode::INTERNAL_SERVER_ERROR,
1855                Json(ErrorResponse::storage_error(format!(
1856                    "query truncation rate: {}",
1857                    e
1858                ))),
1859            )
1860        })?;
1861    Ok(Json(GenAiItemsResponse {
1862        items: serde_json::to_value(&rows).map_err(|e| {
1863            (
1864                StatusCode::INTERNAL_SERVER_ERROR,
1865                Json(ErrorResponse::storage_error(format!(
1866                    "serialize truncation_rate: {e}"
1867                ))),
1868            )
1869        })?,
1870        filters_applied: filters.applied(&FILTER_DIMENSIONS),
1871    }))
1872}
1873
1874/// `by_model=1` (or `true`) enables the cache-economics payload.
1875fn by_model_enabled(v: Option<&str>) -> bool {
1876    matches!(v, Some("1") | Some("true"))
1877}
1878
1879/// Cache token hit rate per model; with `by_model=1` returns the full cache
1880/// economics payload instead (`CacheEconomicsResponse`).
1881#[utoipa::path(
1882    get,
1883    path = "/api/genai/cache_hit_rate",
1884    params(CacheQuery),
1885    responses(
1886        (status = 200, description = "Per-model cache hit rate (default) or cache economics with by_model=1", body = serde_json::Value),
1887        (status = 400, description = "Invalid bucket_secs", body = ErrorResponse),
1888        (status = 500, description = "Internal server error", body = ErrorResponse)
1889    ),
1890    tag = "genai"
1891)]
1892pub async fn get_cache_hit_rate(
1893    State(state): State<AppState>,
1894    Query(query): Query<CacheQuery>,
1895) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
1896    if by_model_enabled(query.by_model.as_deref()) {
1897        let bucket_secs = query.bucket_secs.unwrap_or(3600);
1898        if bucket_secs == 0 {
1899            return Err((
1900                StatusCode::BAD_REQUEST,
1901                Json(ErrorResponse::bad_request(
1902                    "bucket_secs must be a positive number of seconds",
1903                )),
1904            ));
1905        }
1906        let mut response = state
1907            .storage
1908            .query_cache_economics(
1909                query.start_time,
1910                query.end_time,
1911                (bucket_secs as i64) * 1_000_000_000,
1912            )
1913            .await
1914            .map_err(|e| {
1915                (
1916                    StatusCode::INTERNAL_SERVER_ERROR,
1917                    Json(ErrorResponse::storage_error(format!(
1918                        "query cache economics: {}",
1919                        e
1920                    ))),
1921                )
1922            })?;
1923        // Enrich per-model estimated savings. The cache-read price is
1924        // unknown when the pricing table has no entry or no cache-read rate
1925        // for the model — savings stay null and savings_known is false.
1926        let pricing = state.pricing.snapshot().await;
1927        for m in &mut response.models {
1928            let r = pricing
1929                .db
1930                .compute_cache_savings(Some(&m.model), m.cache_read_tokens, None);
1931            m.est_savings_usd = r.cost;
1932            m.savings_known = r.cost.is_some();
1933        }
1934        let value = serde_json::to_value(response).map_err(|e| {
1935            (
1936                StatusCode::INTERNAL_SERVER_ERROR,
1937                Json(ErrorResponse::storage_error(format!(
1938                    "serialize cache economics: {e}"
1939                ))),
1940            )
1941        })?;
1942        // The economics payload is always per-model and drawn from
1943        // opencode metrics — nothing from the bar is applied to it.
1944        let obj = value.as_object().ok_or_else(|| {
1945            (
1946                StatusCode::INTERNAL_SERVER_ERROR,
1947                Json(ErrorResponse::storage_error(
1948                    "cache economics payload is not a JSON object".to_string(),
1949                )),
1950            )
1951        })?;
1952        let mut obj = obj.clone();
1953        obj.insert(
1954            "filters_applied".to_string(),
1955            serde_json::Value::Array(Vec::new()),
1956        );
1957        return Ok(Json(serde_json::Value::Object(obj)));
1958    }
1959
1960    let filters = query.filters();
1961    let rows = state
1962        .storage
1963        .query_cache_hit_rate(query.start_time, query.end_time, &filters)
1964        .await
1965        .map_err(|e| {
1966            (
1967                StatusCode::INTERNAL_SERVER_ERROR,
1968                Json(ErrorResponse::storage_error(format!(
1969                    "query cache hit rate: {}",
1970                    e
1971                ))),
1972            )
1973        })?;
1974    let items = serde_json::to_value(rows).map_err(|e| {
1975        (
1976            StatusCode::INTERNAL_SERVER_ERROR,
1977            Json(ErrorResponse::storage_error(format!(
1978                "serialize cache hit rate: {e}"
1979            ))),
1980        )
1981    })?;
1982    // The per-model rows are an array, so — like the other list endpoints —
1983    // they are wrapped in the standard { items, filters_applied } envelope
1984    // rather than merged into the payload (issue #139: merging required an
1985    // object and 500'd on the array).
1986    Ok(Json(serde_json::json!({
1987        "items": items,
1988        "filters_applied": filters.applied(&FILTER_DIMENSIONS),
1989    })))
1990}
1991
1992/// Reasoning ("thinking") token share per model, plus a global
1993/// per-effort breakdown. Reasoning tokens are priced at the model's output
1994/// rate — that is what thinking costs.
1995#[utoipa::path(
1996    get,
1997    path = "/api/genai/reasoning_share",
1998    params(TimeRangeQuery),
1999    responses(
2000        (status = 200, description = "Reasoning token share by model and effort", body = ReasoningShareResponse),
2001        (status = 500, description = "Internal server error", body = ErrorResponse)
2002    ),
2003    tag = "genai"
2004)]
2005pub async fn get_reasoning_share(
2006    State(state): State<AppState>,
2007    Query(query): Query<TimeRangeQuery>,
2008) -> Result<Json<ReasoningShareResponse>, (StatusCode, Json<ErrorResponse>)> {
2009    let filters = query.filters();
2010    let mut response = state
2011        .storage
2012        .query_reasoning_share(query.start_time, query.end_time)
2013        .await
2014        .map_err(|e| {
2015            (
2016                StatusCode::INTERNAL_SERVER_ERROR,
2017                Json(ErrorResponse::storage_error(format!(
2018                    "query reasoning share: {e}"
2019                ))),
2020            )
2021        })?;
2022
2023    // Enrich per-model cost: reasoning tokens billed at the output rate.
2024    let pricing = state.pricing.snapshot().await;
2025    for m in &mut response.models {
2026        let usage = TokenUsage {
2027            input: 0,
2028            output: m.reasoning_tokens,
2029            cache_creation: 0,
2030            cache_read: 0,
2031        };
2032        m.cost_usd = pricing
2033            .db
2034            .compute_cost(Some(m.model.as_str()), usage, None)
2035            .cost;
2036    }
2037    // The rollup mixes opencode metrics and codex spans; the filter bar's
2038    // dimensions aren't addressable across both parts, so nothing is applied
2039    // and the UI greys the whole bar for this section.
2040    response.filters_applied = filters.applied(&[]);
2041
2042    Ok(Json(response))
2043}
2044
2045/// Per-harness rollup: sessions, cost, tokens, tool calls and retries for
2046/// opencode/codex/claude, sorted by cost descending. opencode's cost is its
2047/// own spend counter ("actual"); codex and claude are estimated from tokens
2048/// x pricing (their cost counters under-report on the live data).
2049#[utoipa::path(
2050    get,
2051    path = "/api/genai/agents",
2052    params(TimeSeriesQuery),
2053    responses(
2054        (status = 200, description = "Per-harness sessions, cost, tokens, tool calls and retries", body = AgentRollupResponse),
2055        (status = 400, description = "Invalid bucket_secs", body = ErrorResponse),
2056        (status = 500, description = "Internal server error", body = ErrorResponse)
2057    ),
2058    tag = "genai"
2059)]
2060pub async fn get_agents(
2061    State(state): State<AppState>,
2062    Query(query): Query<TimeSeriesQuery>,
2063) -> Result<Json<AgentRollupResponse>, (StatusCode, Json<ErrorResponse>)> {
2064    let filters = query.filters();
2065    let bucket_secs = query.bucket_secs.unwrap_or(3600);
2066    if bucket_secs == 0 {
2067        return Err((
2068            StatusCode::BAD_REQUEST,
2069            Json(ErrorResponse::bad_request(
2070                "bucket_secs must be a positive number of seconds",
2071            )),
2072        ));
2073    }
2074
2075    let rollups = state
2076        .storage
2077        .query_agent_rollup(query.start_time, query.end_time, bucket_secs)
2078        .await
2079        .map_err(|e| {
2080            (
2081                StatusCode::INTERNAL_SERVER_ERROR,
2082                Json(ErrorResponse::storage_error(format!(
2083                    "query agent rollup: {e}"
2084                ))),
2085            )
2086        })?;
2087
2088    let pricing = state.pricing.snapshot().await;
2089    let mut agents: Vec<AgentRollup> = rollups.into_iter().map(|r| r.enrich(&pricing.db)).collect();
2090    agents.sort_by(|a, b| {
2091        b.cost_usd
2092            .unwrap_or(0.0)
2093            .partial_cmp(&a.cost_usd.unwrap_or(0.0))
2094            .unwrap_or(std::cmp::Ordering::Equal)
2095            .then_with(|| a.agent.cmp(&b.agent))
2096    });
2097
2098    Ok(Json(AgentRollupResponse {
2099        agents,
2100        filters_applied: filters.applied(&[]),
2101    }))
2102}
2103
2104/// Per-project usage: which project/repo drove the bill. opencode
2105/// attributes by `project.id`; codex/claude emit no project label today, so
2106/// their activity lands in the `"unattributed"` row (stated as
2107/// `cost_source`/`project_id`, not a mapping invented in the query).
2108#[utoipa::path(
2109    get,
2110    path = "/api/genai/projects",
2111    params(TimeRangeQuery),
2112    responses(
2113        (status = 200, description = "Cost, sessions and tokens per project", body = ProjectRollupResponse),
2114        (status = 500, description = "Internal server error", body = ErrorResponse)
2115    ),
2116    tag = "genai"
2117)]
2118pub async fn get_projects(
2119    State(state): State<AppState>,
2120    Query(query): Query<TimeRangeQuery>,
2121) -> Result<Json<ProjectRollupResponse>, (StatusCode, Json<ErrorResponse>)> {
2122    let filters = query.filters();
2123    let rollups = state
2124        .storage
2125        .query_project_rollup(query.start_time, query.end_time)
2126        .await
2127        .map_err(|e| {
2128            (
2129                StatusCode::INTERNAL_SERVER_ERROR,
2130                Json(ErrorResponse::storage_error(format!(
2131                    "query project rollup: {e}"
2132                ))),
2133            )
2134        })?;
2135
2136    let pricing = state.pricing.snapshot().await;
2137    let mut projects: Vec<_> = rollups.into_iter().map(|r| r.enrich(&pricing.db)).collect();
2138    projects.sort_by(|a, b| {
2139        b.cost_usd
2140            .unwrap_or(0.0)
2141            .partial_cmp(&a.cost_usd.unwrap_or(0.0))
2142            .unwrap_or(std::cmp::Ordering::Equal)
2143            .then_with(|| a.project_id.cmp(&b.project_id))
2144    });
2145
2146    Ok(Json(ProjectRollupResponse {
2147        projects,
2148        filters_applied: filters.applied(&[]),
2149    }))
2150}
2151
2152/// Sub-agent role attribution: cost and tokens per opencode `agent` label.
2153#[utoipa::path(
2154    get,
2155    path = "/api/genai/agent_roles",
2156    params(TimeRangeQuery),
2157    responses(
2158        (status = 200, description = "Cost and token attribution per sub-agent role", body = AgentRolesResponse),
2159        (status = 500, description = "Internal server error", body = ErrorResponse)
2160    ),
2161    tag = "genai"
2162)]
2163pub async fn get_agent_roles(
2164    State(state): State<AppState>,
2165    Query(query): Query<TimeRangeQuery>,
2166) -> Result<Json<AgentRolesResponse>, (StatusCode, Json<ErrorResponse>)> {
2167    let filters = query.filters();
2168    let mut response = state
2169        .storage
2170        .query_agent_roles(query.start_time, query.end_time)
2171        .await
2172        .map_err(|e| {
2173            (
2174                StatusCode::INTERNAL_SERVER_ERROR,
2175                Json(ErrorResponse::storage_error(format!(
2176                    "query agent roles: {}",
2177                    e
2178                ))),
2179            )
2180        })?;
2181
2182    // Enrich per-model cost from the pricing table. opencode's own cost
2183    // counter is zero-valued in the wire data, so tokens x price is the only
2184    // source. Reasoning tokens are not priced (no separate rate in the
2185    // pricing table); role.cost covers the top-5 models and is None when any
2186    // of them lacks pricing (e.g. local models).
2187    let pricing = state.pricing.snapshot().await;
2188    for role in &mut response.roles {
2189        let mut total: f64 = 0.0;
2190        let mut all_priced = true;
2191        for m in &mut role.top_models {
2192            let usage = TokenUsage {
2193                input: m.tokens.input,
2194                output: m.tokens.output,
2195                cache_creation: m.tokens.cache_write,
2196                cache_read: m.tokens.cache_read,
2197            };
2198            let result = pricing.db.compute_cost(Some(m.model.as_str()), usage, None);
2199            m.cost = result.cost;
2200            m.cost_source = Some(result.source.as_str().to_string());
2201            m.cost_reason = result.reason;
2202            match result.cost {
2203                Some(c) => total += c,
2204                None => all_priced = false,
2205            }
2206        }
2207        role.cost = if all_priced && !role.top_models.is_empty() {
2208            Some(total)
2209        } else {
2210            None
2211        };
2212    }
2213    response.filters_applied = filters.applied(&[]);
2214
2215    Ok(Json(response))
2216}
2217
2218/// Provider × model mix: tokens, sessions and estimated cost per provider
2219/// and model, across opencode, codex and claude_code.
2220#[utoipa::path(
2221    get,
2222    path = "/api/genai/provider_mix",
2223    params(TimeRangeQuery),
2224    responses(
2225        (status = 200, description = "Provider x model token and cost mix", body = ProviderMixResponse),
2226        (status = 500, description = "Internal server error", body = ErrorResponse)
2227    ),
2228    tag = "genai"
2229)]
2230pub async fn get_provider_mix(
2231    State(state): State<AppState>,
2232    Query(query): Query<TimeRangeQuery>,
2233) -> Result<Json<ProviderMixResponse>, (StatusCode, Json<ErrorResponse>)> {
2234    let filters = query.filters();
2235    let mut response = state
2236        .storage
2237        .query_provider_mix(query.start_time, query.end_time)
2238        .await
2239        .map_err(|e| {
2240            (
2241                StatusCode::INTERNAL_SERVER_ERROR,
2242                Json(ErrorResponse::storage_error(format!(
2243                    "query provider mix: {}",
2244                    e
2245                ))),
2246            )
2247        })?;
2248
2249    // Enrich per-model cost from the pricing table (opencode's own cost
2250    // counter is zero-valued in the wire data, so tokens x price is the
2251    // source; reasoning tokens are not priced). Provider cost covers its
2252    // priced models; None when none of them has known pricing.
2253    let pricing = state.pricing.snapshot().await;
2254    for provider in &mut response.providers {
2255        let mut total: f64 = 0.0;
2256        let mut any_priced = false;
2257        for m in &mut provider.models {
2258            let usage = TokenUsage {
2259                input: m.tokens.input,
2260                output: m.tokens.output,
2261                cache_creation: m.tokens.cache_write,
2262                cache_read: m.tokens.cache_read,
2263            };
2264            let result = pricing.db.compute_cost(Some(m.model.as_str()), usage, None);
2265            if let Some(c) = result.cost {
2266                total += c;
2267                any_priced = true;
2268            }
2269            m.cost_usd = result.cost;
2270            m.cost_source = Some(result.source.as_str().to_string());
2271        }
2272        provider.cost_usd = if any_priced { Some(total) } else { None };
2273    }
2274    response.filters_applied = filters.applied(&[]);
2275
2276    Ok(Json(response))
2277}
2278
2279/// Distribution of request parameter settings (temperature, max_tokens).
2280#[utoipa::path(
2281    get,
2282    path = "/api/genai/request_param_profile",
2283    params(TimeRangeQuery),
2284    responses(
2285        (status = 200, description = "Request parameter profile", body = RequestParamProfile),
2286        (status = 500, description = "Internal server error", body = ErrorResponse)
2287    ),
2288    tag = "genai"
2289)]
2290pub async fn get_request_param_profile(
2291    State(state): State<AppState>,
2292    Query(query): Query<TimeRangeQuery>,
2293) -> Result<Json<RequestParamProfile>, (StatusCode, Json<ErrorResponse>)> {
2294    let filters = query.filters();
2295    let mut profile = state
2296        .storage
2297        .query_request_param_profile(query.start_time, query.end_time, &filters)
2298        .await
2299        .map_err(|e| {
2300            (
2301                StatusCode::INTERNAL_SERVER_ERROR,
2302                Json(ErrorResponse::storage_error(format!(
2303                    "query request param profile: {}",
2304                    e
2305                ))),
2306            )
2307        })?;
2308    profile.filters_applied = filters.applied(&FILTER_DIMENSIONS);
2309
2310    Ok(Json(profile))
2311}
2312
2313/// Turn-count distribution across conversations.
2314#[utoipa::path(
2315    get,
2316    path = "/api/genai/conversation_depth",
2317    params(TimeRangeQuery),
2318    responses(
2319        (status = 200, description = "Conversation depth statistics", body = ConversationDepthStats),
2320        (status = 500, description = "Internal server error", body = ErrorResponse)
2321    ),
2322    tag = "genai"
2323)]
2324pub async fn get_conversation_depth(
2325    State(state): State<AppState>,
2326    Query(query): Query<TimeRangeQuery>,
2327) -> Result<Json<ConversationDepthStats>, (StatusCode, Json<ErrorResponse>)> {
2328    let filters = query.filters();
2329    let mut stats = state
2330        .storage
2331        .query_conversation_depth(query.start_time, query.end_time, &filters)
2332        .await
2333        .map_err(|e| {
2334            (
2335                StatusCode::INTERNAL_SERVER_ERROR,
2336                Json(ErrorResponse::storage_error(format!(
2337                    "query conversation depth: {}",
2338                    e
2339                ))),
2340            )
2341        })?;
2342    stats.filters_applied = filters.applied(&FILTER_DIMENSIONS);
2343
2344    Ok(Json(stats))
2345}
2346
2347/// LLM span latency (min/avg/p95/max + TTFT) per time bucket, grouped by model.
2348#[utoipa::path(
2349    get,
2350    path = "/api/genai/latency_series",
2351    params(ModelTimeSeriesQuery),
2352    responses(
2353        (status = 200, description = "Latency stats per time bucket", body = GenAiItemsResponse),
2354        (status = 500, description = "Internal server error", body = ErrorResponse)
2355    ),
2356    tag = "genai"
2357)]
2358pub async fn get_latency_series(
2359    State(state): State<AppState>,
2360    Query(query): Query<ModelTimeSeriesQuery>,
2361) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2362    let filters = query.filters();
2363    let bucket_secs = query.bucket_secs.unwrap_or(3600).clamp(60, 86400);
2364    let all_spans = query.span_filter.as_deref() == Some("all");
2365    let rows = state
2366        .storage
2367        .query_latency_series(
2368            query.start_time,
2369            query.end_time,
2370            bucket_secs,
2371            &filters,
2372            all_spans,
2373            None,
2374        )
2375        .await
2376        .map_err(|e| {
2377            (
2378                StatusCode::INTERNAL_SERVER_ERROR,
2379                Json(ErrorResponse::storage_error(format!(
2380                    "query latency series: {}",
2381                    e
2382                ))),
2383            )
2384        })?;
2385    Ok(Json(GenAiItemsResponse {
2386        items: serde_json::to_value(&rows).map_err(|e| {
2387            (
2388                StatusCode::INTERNAL_SERVER_ERROR,
2389                Json(ErrorResponse::storage_error(format!(
2390                    "serialize latency_series: {e}"
2391                ))),
2392            )
2393        })?,
2394        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2395    }))
2396}
2397
2398/// LLM call volume over time (parallel to cost_series).
2399#[utoipa::path(
2400    get,
2401    path = "/api/genai/calls_series",
2402    params(TimeSeriesQuery),
2403    responses(
2404        (status = 200, description = "Calls per time bucket", body = GenAiItemsResponse),
2405        (status = 500, description = "Internal server error", body = ErrorResponse)
2406    ),
2407    tag = "genai"
2408)]
2409pub async fn get_calls_series(
2410    State(state): State<AppState>,
2411    Query(query): Query<TimeSeriesQuery>,
2412) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2413    let filters = query.filters();
2414    let bucket_secs = query.bucket_secs.unwrap_or(3600).clamp(60, 86400);
2415    let all_spans = query.span_filter.as_deref() == Some("all");
2416    let rows = state
2417        .storage
2418        .query_calls_series(
2419            query.start_time,
2420            query.end_time,
2421            &filters,
2422            bucket_secs,
2423            all_spans,
2424        )
2425        .await
2426        .map_err(|e| {
2427            (
2428                StatusCode::INTERNAL_SERVER_ERROR,
2429                Json(ErrorResponse::storage_error(format!(
2430                    "query calls series: {}",
2431                    e
2432                ))),
2433            )
2434        })?;
2435    Ok(Json(GenAiItemsResponse {
2436        items: serde_json::to_value(&rows).map_err(|e| {
2437            (
2438                StatusCode::INTERNAL_SERVER_ERROR,
2439                Json(ErrorResponse::storage_error(format!(
2440                    "serialize calls_series: {e}"
2441                ))),
2442            )
2443        })?,
2444        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2445    }))
2446}
2447
2448/// LLM latency broken down by input-token context size bin × model.
2449/// Useful for answering "do larger prompts cause slower responses?"
2450#[utoipa::path(
2451    get,
2452    path = "/api/genai/latency_by_context",
2453    params(ModelAnalyticsQuery),
2454    responses(
2455        (status = 200, description = "Latency per context size bin", body = GenAiItemsResponse),
2456        (status = 500, description = "Internal server error", body = ErrorResponse)
2457    ),
2458    tag = "genai"
2459)]
2460pub async fn get_latency_by_context(
2461    State(state): State<AppState>,
2462    Query(query): Query<ModelAnalyticsQuery>,
2463) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2464    let filters = query.filters();
2465
2466    let rows = state
2467        .storage
2468        .query_latency_by_context(query.start_time, query.end_time, &filters)
2469        .await
2470        .map_err(|e| {
2471            (
2472                StatusCode::INTERNAL_SERVER_ERROR,
2473                Json(ErrorResponse::storage_error(format!(
2474                    "query latency by context: {}",
2475                    e
2476                ))),
2477            )
2478        })?;
2479    Ok(Json(GenAiItemsResponse {
2480        items: serde_json::to_value(&rows).map_err(|e| {
2481            (
2482                StatusCode::INTERNAL_SERVER_ERROR,
2483                Json(ErrorResponse::storage_error(format!(
2484                    "serialize latency_by_context: {e}"
2485                ))),
2486            )
2487        })?,
2488        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2489    }))
2490}
2491
2492/// Per-(model, error_type) breakdown of error spans, bucketed into actionable categories.
2493#[utoipa::path(
2494    get,
2495    path = "/api/genai/error_types",
2496    params(ModelAnalyticsQuery),
2497    responses(
2498        (status = 200, description = "Error type breakdown per model", body = GenAiItemsResponse),
2499        (status = 500, description = "Internal server error", body = ErrorResponse)
2500    ),
2501    tag = "genai"
2502)]
2503pub async fn get_error_types(
2504    State(state): State<AppState>,
2505    Query(query): Query<ModelAnalyticsQuery>,
2506) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2507    let filters = query.filters();
2508
2509    let rows = state
2510        .storage
2511        .query_error_types(query.start_time, query.end_time, &filters)
2512        .await
2513        .map_err(|e| {
2514            (
2515                StatusCode::INTERNAL_SERVER_ERROR,
2516                Json(ErrorResponse::storage_error(format!(
2517                    "query error types: {}",
2518                    e
2519                ))),
2520            )
2521        })?;
2522    Ok(Json(GenAiItemsResponse {
2523        items: serde_json::to_value(&rows).map_err(|e| {
2524            (
2525                StatusCode::INTERNAL_SERVER_ERROR,
2526                Json(ErrorResponse::storage_error(format!(
2527                    "serialize error_types: {e}"
2528                ))),
2529            )
2530        })?,
2531        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2532    }))
2533}
2534
2535/// All observed (request_model → response_model) pairs with a `differs` flag.
2536/// `differs == true` indicates silent provider rerouting.
2537#[utoipa::path(
2538    get,
2539    path = "/api/genai/model_drift",
2540    params(TimeRangeQuery),
2541    responses(
2542        (status = 200, description = "Request→response model pairs", body = GenAiItemsResponse),
2543        (status = 500, description = "Internal server error", body = ErrorResponse)
2544    ),
2545    tag = "genai"
2546)]
2547pub async fn get_model_drift(
2548    State(state): State<AppState>,
2549    Query(query): Query<TimeRangeQuery>,
2550) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2551    let filters = query.filters();
2552
2553    let rows = state
2554        .storage
2555        .query_model_drift(query.start_time, query.end_time, &filters)
2556        .await
2557        .map_err(|e| {
2558            (
2559                StatusCode::INTERNAL_SERVER_ERROR,
2560                Json(ErrorResponse::storage_error(format!(
2561                    "query model drift: {}",
2562                    e
2563                ))),
2564            )
2565        })?;
2566    Ok(Json(GenAiItemsResponse {
2567        items: serde_json::to_value(&rows).map_err(|e| {
2568            (
2569                StatusCode::INTERNAL_SERVER_ERROR,
2570                Json(ErrorResponse::storage_error(format!(
2571                    "serialize model_drift: {e}"
2572                ))),
2573            )
2574        })?,
2575        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2576    }))
2577}
2578
2579/// Tool approval/rejection summary (claude_code.tool.blocked_on_user spans).
2580#[utoipa::path(
2581    get,
2582    path = "/api/genai/tool_approvals",
2583    params(TimeRangeQuery),
2584    responses(
2585        (status = 200, description = "Tool approval statistics", body = ToolApprovalStats),
2586        (status = 500, description = "Internal server error", body = ErrorResponse)
2587    ),
2588    tag = "genai"
2589)]
2590pub async fn get_tool_approvals(
2591    State(state): State<AppState>,
2592    Query(query): Query<TimeRangeQuery>,
2593) -> Result<Json<ToolApprovalStats>, (StatusCode, Json<ErrorResponse>)> {
2594    let filters = query.filters();
2595    let mut stats = state
2596        .storage
2597        .query_tool_approvals(query.start_time, query.end_time, &filters)
2598        .await
2599        .map_err(|e| {
2600            (
2601                StatusCode::INTERNAL_SERVER_ERROR,
2602                Json(ErrorResponse::storage_error(format!(
2603                    "query tool approvals: {}",
2604                    e
2605                ))),
2606            )
2607        })?;
2608    stats.filters_applied = filters.applied(&FILTER_DIMENSIONS);
2609
2610    Ok(Json(stats))
2611}
2612
2613/// Distribution of stop_reason values across LLM spans.
2614#[utoipa::path(
2615    get,
2616    path = "/api/genai/stop_reasons",
2617    params(TimeRangeQuery),
2618    responses(
2619        (status = 200, description = "Stop reason distribution", body = GenAiItemsResponse),
2620        (status = 500, description = "Internal server error", body = ErrorResponse)
2621    ),
2622    tag = "genai"
2623)]
2624pub async fn get_stop_reasons(
2625    State(state): State<AppState>,
2626    Query(query): Query<TimeRangeQuery>,
2627) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2628    let filters = query.filters();
2629
2630    let rows = state
2631        .storage
2632        .query_stop_reasons(query.start_time, query.end_time, &filters)
2633        .await
2634        .map_err(|e| {
2635            (
2636                StatusCode::INTERNAL_SERVER_ERROR,
2637                Json(ErrorResponse::storage_error(format!(
2638                    "query stop reasons: {}",
2639                    e
2640                ))),
2641            )
2642        })?;
2643    Ok(Json(GenAiItemsResponse {
2644        items: serde_json::to_value(&rows).map_err(|e| {
2645            (
2646                StatusCode::INTERNAL_SERVER_ERROR,
2647                Json(ErrorResponse::storage_error(format!(
2648                    "serialize stop_reasons: {e}"
2649                ))),
2650            )
2651        })?,
2652        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2653    }))
2654}
2655
2656/// Token usage broken down by llm_request.context type.
2657#[utoipa::path(
2658    get,
2659    path = "/api/genai/context_type_split",
2660    params(TimeRangeQuery),
2661    responses(
2662        (status = 200, description = "Context type token split", body = GenAiItemsResponse),
2663        (status = 500, description = "Internal server error", body = ErrorResponse)
2664    ),
2665    tag = "genai"
2666)]
2667pub async fn get_context_type_split(
2668    State(state): State<AppState>,
2669    Query(query): Query<TimeRangeQuery>,
2670) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2671    let filters = query.filters();
2672
2673    let rows = state
2674        .storage
2675        .query_context_type_split(query.start_time, query.end_time, &filters)
2676        .await
2677        .map_err(|e| {
2678            (
2679                StatusCode::INTERNAL_SERVER_ERROR,
2680                Json(ErrorResponse::storage_error(format!(
2681                    "query context type split: {}",
2682                    e
2683                ))),
2684            )
2685        })?;
2686    Ok(Json(GenAiItemsResponse {
2687        items: serde_json::to_value(&rows).map_err(|e| {
2688            (
2689                StatusCode::INTERNAL_SERVER_ERROR,
2690                Json(ErrorResponse::storage_error(format!(
2691                    "serialize context_type_split: {e}"
2692                ))),
2693            )
2694        })?,
2695        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2696    }))
2697}
2698
2699/// Top error messages from failed tool executions.
2700#[utoipa::path(
2701    get,
2702    path = "/api/genai/tool_errors",
2703    params(ToolUsageQuery),
2704    responses(
2705        (status = 200, description = "Tool error messages", body = GenAiItemsResponse),
2706        (status = 500, description = "Internal server error", body = ErrorResponse)
2707    ),
2708    tag = "genai"
2709)]
2710pub async fn get_tool_errors(
2711    State(state): State<AppState>,
2712    Query(query): Query<ToolUsageQuery>,
2713) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2714    let limit = query.limit.unwrap_or(30).clamp(1, 100);
2715    let filters = query.filters();
2716    let rows = state
2717        .storage
2718        .query_tool_errors(query.start_time, query.end_time, &filters, limit)
2719        .await
2720        .map_err(|e| {
2721            (
2722                StatusCode::INTERNAL_SERVER_ERROR,
2723                Json(ErrorResponse::storage_error(format!(
2724                    "query tool errors: {}",
2725                    e
2726                ))),
2727            )
2728        })?;
2729    Ok(Json(GenAiItemsResponse {
2730        items: serde_json::to_value(&rows).map_err(|e| {
2731            (
2732                StatusCode::INTERNAL_SERVER_ERROR,
2733                Json(ErrorResponse::storage_error(format!(
2734                    "serialize tool_errors: {e}"
2735                ))),
2736            )
2737        })?,
2738        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2739    }))
2740}
2741
2742/// Hour-of-day activity distribution (UTC).
2743#[utoipa::path(
2744    get,
2745    path = "/api/genai/hour_of_day",
2746    params(TimeRangeQuery),
2747    responses(
2748        (status = 200, description = "Hour-of-day buckets", body = GenAiItemsResponse),
2749        (status = 500, description = "Internal server error", body = ErrorResponse)
2750    ),
2751    tag = "genai"
2752)]
2753pub async fn get_hour_of_day(
2754    State(state): State<AppState>,
2755    Query(query): Query<TimeRangeQuery>,
2756) -> Result<Json<GenAiItemsResponse>, (StatusCode, Json<ErrorResponse>)> {
2757    let filters = query.filters();
2758
2759    let rows = state
2760        .storage
2761        .query_hour_of_day(query.start_time, query.end_time, &filters)
2762        .await
2763        .map_err(|e| {
2764            (
2765                StatusCode::INTERNAL_SERVER_ERROR,
2766                Json(ErrorResponse::storage_error(format!(
2767                    "query hour of day: {}",
2768                    e
2769                ))),
2770            )
2771        })?;
2772    Ok(Json(GenAiItemsResponse {
2773        items: serde_json::to_value(&rows).map_err(|e| {
2774            (
2775                StatusCode::INTERNAL_SERVER_ERROR,
2776                Json(ErrorResponse::storage_error(format!(
2777                    "serialize hour_of_day: {e}"
2778                ))),
2779            )
2780        })?,
2781        filters_applied: filters.applied(&FILTER_DIMENSIONS),
2782    }))
2783}
2784
2785/// Claude Code effort breakdown by effort level × model × token type (#157).
2786#[utoipa::path(
2787    get,
2788    path = "/api/genai/effort_breakdown",
2789    params(TimeRangeQuery),
2790    responses(
2791        (status = 200, description = "Effort breakdown", body = otelite_core::api::EffortBreakdownResponse),
2792        (status = 500, description = "Internal server error", body = ErrorResponse)
2793    ),
2794    tag = "genai"
2795)]
2796pub async fn get_effort_breakdown(
2797    State(state): State<AppState>,
2798    Query(query): Query<TimeRangeQuery>,
2799) -> Result<Json<otelite_core::api::EffortBreakdownResponse>, (StatusCode, Json<ErrorResponse>)> {
2800    let mut response = state
2801        .storage
2802        .query_effort_breakdown(query.start_time, query.end_time)
2803        .await
2804        .map_err(|e| {
2805            (
2806                StatusCode::INTERNAL_SERVER_ERROR,
2807                Json(ErrorResponse::storage_error(format!(
2808                    "query effort breakdown: {e}"
2809                ))),
2810            )
2811        })?;
2812    response.filters_applied = query.filters().applied(&[]);
2813    Ok(Json(response))
2814}
2815
2816/// Cross-agent efficiency stats: tokens/commit, tokens/LOC (#158).
2817#[utoipa::path(
2818    get,
2819    path = "/api/genai/efficiency_stats",
2820    params(TimeRangeQuery),
2821    responses(
2822        (status = 200, description = "Efficiency stats", body = otelite_core::api::EfficiencyStats),
2823        (status = 500, description = "Internal server error", body = ErrorResponse)
2824    ),
2825    tag = "genai"
2826)]
2827pub async fn get_efficiency_stats(
2828    State(state): State<AppState>,
2829    Query(query): Query<TimeRangeQuery>,
2830) -> Result<Json<otelite_core::api::EfficiencyStats>, (StatusCode, Json<ErrorResponse>)> {
2831    let mut response = state
2832        .storage
2833        .query_efficiency_stats(query.start_time, query.end_time)
2834        .await
2835        .map_err(|e| {
2836            (
2837                StatusCode::INTERNAL_SERVER_ERROR,
2838                Json(ErrorResponse::storage_error(format!(
2839                    "query efficiency stats: {e}"
2840                ))),
2841            )
2842        })?;
2843    response.filters_applied = query.filters().applied(&[]);
2844    Ok(Json(response))
2845}
2846
2847/// Codex TTFT histogram percentiles per model (#159).
2848#[utoipa::path(
2849    get,
2850    path = "/api/genai/codex_ttft",
2851    params(TimeRangeQuery),
2852    responses(
2853        (status = 200, description = "Codex TTFT percentiles", body = otelite_core::api::CodexTtftResponse),
2854        (status = 500, description = "Internal server error", body = ErrorResponse)
2855    ),
2856    tag = "genai"
2857)]
2858pub async fn get_codex_ttft(
2859    State(state): State<AppState>,
2860    Query(query): Query<TimeRangeQuery>,
2861) -> Result<Json<otelite_core::api::CodexTtftResponse>, (StatusCode, Json<ErrorResponse>)> {
2862    let mut response = state
2863        .storage
2864        .query_codex_ttft(query.start_time, query.end_time)
2865        .await
2866        .map_err(|e| {
2867            (
2868                StatusCode::INTERNAL_SERVER_ERROR,
2869                Json(ErrorResponse::storage_error(format!(
2870                    "query codex ttft: {e}"
2871                ))),
2872            )
2873        })?;
2874    response.filters_applied = query.filters().applied(&[]);
2875    Ok(Json(response))
2876}
2877
2878/// Per-project token + commit rollup across all agents (#160).
2879#[utoipa::path(
2880    get,
2881    path = "/api/genai/project_rollup",
2882    params(TimeRangeQuery),
2883    responses(
2884        (status = 200, description = "Per-project rollup", body = otelite_core::api::AgentProjectRollupResponse),
2885        (status = 500, description = "Internal server error", body = ErrorResponse)
2886    ),
2887    tag = "genai"
2888)]
2889pub async fn get_agent_project_rollup(
2890    State(state): State<AppState>,
2891    Query(query): Query<TimeRangeQuery>,
2892) -> Result<Json<otelite_core::api::AgentProjectRollupResponse>, (StatusCode, Json<ErrorResponse>)>
2893{
2894    let mut response = state
2895        .storage
2896        .query_agent_project_rollup(query.start_time, query.end_time)
2897        .await
2898        .map_err(|e| {
2899            (
2900                StatusCode::INTERNAL_SERVER_ERROR,
2901                Json(ErrorResponse::storage_error(format!(
2902                    "query project rollup: {e}"
2903                ))),
2904            )
2905        })?;
2906    response.filters_applied = query.filters().applied(&[]);
2907    Ok(Json(response))
2908}
2909
2910/// MCP call success/error health per server+tool (#161).
2911#[utoipa::path(
2912    get,
2913    path = "/api/genai/mcp_health",
2914    params(TimeRangeQuery),
2915    responses(
2916        (status = 200, description = "MCP health", body = otelite_core::api::McpHealthResponse),
2917        (status = 500, description = "Internal server error", body = ErrorResponse)
2918    ),
2919    tag = "genai"
2920)]
2921pub async fn get_mcp_health(
2922    State(state): State<AppState>,
2923    Query(query): Query<TimeRangeQuery>,
2924) -> Result<Json<otelite_core::api::McpHealthResponse>, (StatusCode, Json<ErrorResponse>)> {
2925    let mut response = state
2926        .storage
2927        .query_mcp_health(query.start_time, query.end_time)
2928        .await
2929        .map_err(|e| {
2930            (
2931                StatusCode::INTERNAL_SERVER_ERROR,
2932                Json(ErrorResponse::storage_error(format!(
2933                    "query mcp health: {e}"
2934                ))),
2935            )
2936        })?;
2937    response.filters_applied = query.filters().applied(&[]);
2938    Ok(Json(response))
2939}
2940
2941/// Codex Guardian review summary by risk level and action (#162).
2942#[utoipa::path(
2943    get,
2944    path = "/api/genai/guardian_stats",
2945    params(TimeRangeQuery),
2946    responses(
2947        (status = 200, description = "Guardian stats", body = otelite_core::api::GuardianStatsResponse),
2948        (status = 500, description = "Internal server error", body = ErrorResponse)
2949    ),
2950    tag = "genai"
2951)]
2952pub async fn get_guardian_stats(
2953    State(state): State<AppState>,
2954    Query(query): Query<TimeRangeQuery>,
2955) -> Result<Json<otelite_core::api::GuardianStatsResponse>, (StatusCode, Json<ErrorResponse>)> {
2956    let mut response = state
2957        .storage
2958        .query_guardian_stats(query.start_time, query.end_time)
2959        .await
2960        .map_err(|e| {
2961            (
2962                StatusCode::INTERNAL_SERVER_ERROR,
2963                Json(ErrorResponse::storage_error(format!(
2964                    "query guardian stats: {e}"
2965                ))),
2966            )
2967        })?;
2968    response.filters_applied = query.filters().applied(&[]);
2969    Ok(Json(response))
2970}
2971
2972/// Codex multi-agent spawn/resume topology by role (#163).
2973#[utoipa::path(
2974    get,
2975    path = "/api/genai/multi_agent_stats",
2976    params(TimeRangeQuery),
2977    responses(
2978        (status = 200, description = "Multi-agent topology", body = otelite_core::api::MultiAgentStatsResponse),
2979        (status = 500, description = "Internal server error", body = ErrorResponse)
2980    ),
2981    tag = "genai"
2982)]
2983pub async fn get_multi_agent_stats(
2984    State(state): State<AppState>,
2985    Query(query): Query<TimeRangeQuery>,
2986) -> Result<Json<otelite_core::api::MultiAgentStatsResponse>, (StatusCode, Json<ErrorResponse>)> {
2987    let mut response = state
2988        .storage
2989        .query_multi_agent_stats(query.start_time, query.end_time)
2990        .await
2991        .map_err(|e| {
2992            (
2993                StatusCode::INTERNAL_SERVER_ERROR,
2994                Json(ErrorResponse::storage_error(format!(
2995                    "query multi_agent stats: {e}"
2996                ))),
2997            )
2998        })?;
2999    response.filters_applied = query.filters().applied(&[]);
3000    Ok(Json(response))
3001}
3002
3003/// Codex turn busy vs idle breakdown per model and project (#164).
3004#[utoipa::path(
3005    get,
3006    path = "/api/genai/codex_turn_breakdown",
3007    params(TimeRangeQuery),
3008    responses(
3009        (status = 200, description = "Codex turn busy/idle breakdown", body = otelite_core::api::CodexTurnBreakdownResponse),
3010        (status = 500, description = "Internal server error", body = ErrorResponse)
3011    ),
3012    tag = "genai"
3013)]
3014pub async fn get_codex_turn_breakdown(
3015    State(state): State<AppState>,
3016    Query(query): Query<TimeRangeQuery>,
3017) -> Result<Json<otelite_core::api::CodexTurnBreakdownResponse>, (StatusCode, Json<ErrorResponse>)>
3018{
3019    let mut response = state
3020        .storage
3021        .query_codex_turn_breakdown(query.start_time, query.end_time)
3022        .await
3023        .map_err(|e| {
3024            (
3025                StatusCode::INTERNAL_SERVER_ERROR,
3026                Json(ErrorResponse::storage_error(format!(
3027                    "query codex_turn_breakdown: {e}"
3028                ))),
3029            )
3030        })?;
3031    response.filters_applied = query.filters().applied(&[]);
3032    Ok(Json(response))
3033}
3034
3035/// Codex idle-ratio series per UTC day (#181): how the model-wait
3036/// share of turn time trends over time. High ratio = the model is the
3037/// bottleneck; low ratio = tool execution dominates.
3038#[utoipa::path(
3039    get,
3040    path = "/api/genai/codex_idle_ratio",
3041    params(TimeRangeQuery),
3042    responses(
3043        (status = 200, description = "Per-day Codex idle ratio series", body = otelite_core::api::CodexIdleRatioResponse),
3044        (status = 500, description = "Internal server error", body = ErrorResponse)
3045    ),
3046    tag = "genai"
3047)]
3048pub async fn get_codex_idle_ratio(
3049    State(state): State<AppState>,
3050    Query(query): Query<TimeRangeQuery>,
3051) -> Result<Json<otelite_core::api::CodexIdleRatioResponse>, (StatusCode, Json<ErrorResponse>)> {
3052    let mut response = state
3053        .storage
3054        .query_codex_idle_ratio_series(query.start_time, query.end_time)
3055        .await
3056        .map_err(|e| {
3057            (
3058                StatusCode::INTERNAL_SERVER_ERROR,
3059                Json(ErrorResponse::storage_error(format!(
3060                    "query codex_idle_ratio: {e}"
3061                ))),
3062            )
3063        })?;
3064    response.filters_applied = query.filters().applied(&[]);
3065    Ok(Json(response))
3066}
3067
3068/// Session-duration distribution (#183): which length your sessions
3069/// have, per tool. opencode is measured from its own
3070/// `opencode.session.duration` metric; the other tools are
3071/// approximated from the span time range. Very short sessions may
3072/// indicate context failures or frustration; very long ones may be
3073/// inefficient.
3074#[utoipa::path(
3075    get,
3076    path = "/api/genai/session_duration",
3077    params(TimeRangeQuery),
3078    responses(
3079        (status = 200, description = "Per-(tool, bucket) duration histogram and scalar stats", body = otelite_core::api::SessionDurationResponse),
3080        (status = 500, description = "Internal server error", body = ErrorResponse)
3081    ),
3082    tag = "genai"
3083)]
3084pub async fn get_session_duration(
3085    State(state): State<AppState>,
3086    Query(query): Query<TimeRangeQuery>,
3087) -> Result<Json<otelite_core::api::SessionDurationResponse>, (StatusCode, Json<ErrorResponse>)> {
3088    let resp = state
3089        .storage
3090        .query_session_durations(query.start_time, query.end_time)
3091        .await
3092        .map_err(|e| {
3093            (
3094                StatusCode::INTERNAL_SERVER_ERROR,
3095                Json(ErrorResponse::storage_error(format!(
3096                    "query session_duration: {e}"
3097                ))),
3098            )
3099        })?;
3100    let mut response = otelite_core::session_duration::distribution(&resp.rows);
3101    response.filters_applied = query.filters().applied(&[]);
3102    Ok(Json(response))
3103}
3104
3105/// Lines-of-code efficiency per (tool, model) (#178): what producing
3106/// 100 added lines cost. Only the tools that emit an LOC metric
3107/// appear (claude_code, opencode); unpriced models read as null,
3108/// never as a fabricated zero.
3109#[utoipa::path(
3110    get,
3111    path = "/api/genai/loc_efficiency",
3112    params(TimeRangeQuery),
3113    responses(
3114        (status = 200, description = "Lines-of-code efficiency per (tool, model)", body = otelite_core::api::LocEfficiencyResponse),
3115        (status = 500, description = "Internal server error", body = ErrorResponse)
3116    ),
3117    tag = "genai"
3118)]
3119pub async fn get_loc_efficiency(
3120    State(state): State<AppState>,
3121    Query(query): Query<TimeRangeQuery>,
3122) -> Result<Json<otelite_core::api::LocEfficiencyResponse>, (StatusCode, Json<ErrorResponse>)> {
3123    use otelite_core::pricing::TokenUsage;
3124
3125    let resp = state
3126        .storage
3127        .query_loc_efficiency(query.start_time, query.end_time)
3128        .await
3129        .map_err(|e| {
3130            (
3131                StatusCode::INTERNAL_SERVER_ERROR,
3132                Json(ErrorResponse::storage_error(format!(
3133                    "query loc_efficiency: {e}"
3134                ))),
3135            )
3136        })?;
3137
3138    let pricing = state.pricing.snapshot().await;
3139    let rows = otelite_core::loc_efficiency::price_rows(
3140        |model, usage: TokenUsage| pricing.db.compute_cost(Some(model), usage, None).cost,
3141        &resp.rows,
3142    );
3143
3144    let mut response = otelite_core::api::LocEfficiencyResponse {
3145        rows,
3146        filters_applied: Vec::new(),
3147    };
3148    response.filters_applied = query.filters().applied(&[]);
3149    Ok(Json(response))
3150}
3151
3152/// Query parameters for GET /api/genai/rare_tool_sessions (#176).
3153#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
3154pub struct RareToolSessionsQuery {
3155    /// Tools with fewer sessions in the window than this are "rare"
3156    /// (default 10). The main tools (claude_code, opencode, codex, bob)
3157    /// are always excluded, whatever the threshold.
3158    pub rare_session_threshold: Option<u64>,
3159    /// Start time (nanoseconds since Unix epoch)
3160    pub start_time: Option<i64>,
3161    /// End time (nanoseconds since Unix epoch)
3162    pub end_time: Option<i64>,
3163}
3164
3165/// Rare-tool session summary (#176): pi, deepseek, and any
3166/// experimental harness outside the main-tool set — when each session
3167/// ran, which model, how much it cost, and a task hint from the
3168/// dominant span name.
3169#[utoipa::path(
3170    get,
3171    path = "/api/genai/rare_tool_sessions",
3172    params(RareToolSessionsQuery),
3173    responses(
3174        (status = 200, description = "Rare-tool sessions, newest first", body = otelite_core::api::RareToolSessionsResponse),
3175        (status = 400, description = "Invalid rare_session_threshold", body = ErrorResponse),
3176        (status = 500, description = "Internal server error", body = ErrorResponse)
3177    ),
3178    tag = "genai"
3179)]
3180pub async fn get_rare_tool_sessions(
3181    State(state): State<AppState>,
3182    Query(query): Query<RareToolSessionsQuery>,
3183) -> Result<Json<otelite_core::api::RareToolSessionsResponse>, (StatusCode, Json<ErrorResponse>)> {
3184    use otelite_core::pricing::TokenUsage;
3185    use otelite_core::rare_tools;
3186
3187    const MAX_RARE_THRESHOLD: u64 = 1000;
3188    let threshold = query
3189        .rare_session_threshold
3190        .unwrap_or(rare_tools::DEFAULT_RARE_SESSION_THRESHOLD);
3191    if threshold == 0 || threshold > MAX_RARE_THRESHOLD {
3192        return Err((
3193            StatusCode::BAD_REQUEST,
3194            Json(ErrorResponse::bad_request(format!(
3195                "rare_session_threshold must be between 1 and {MAX_RARE_THRESHOLD} sessions, got {threshold}"
3196            ))),
3197        ));
3198    }
3199
3200    let resp = state
3201        .storage
3202        .query_rare_tool_sessions(query.start_time, query.end_time)
3203        .await
3204        .map_err(|e| {
3205            (
3206                StatusCode::INTERNAL_SERVER_ERROR,
3207                Json(ErrorResponse::storage_error(format!(
3208                    "query rare_tool_sessions: {e}"
3209                ))),
3210            )
3211        })?;
3212
3213    let pricing = state.pricing.snapshot().await;
3214    let rows = rare_tools::build_sessions(
3215        &resp.rows,
3216        &resp.span_names,
3217        threshold,
3218        |model, usage: TokenUsage| pricing.db.compute_cost(Some(model), usage, None).cost,
3219    );
3220
3221    Ok(Json(otelite_core::api::RareToolSessionsResponse {
3222        rows,
3223        filters_applied: Vec::new(),
3224    }))
3225}
3226
3227/// Query parameters for GET /api/genai/human_response_latency (#171).
3228#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
3229pub struct HumanResponseLatencyQuery {
3230    /// Gap cap in seconds: gaps beyond it are context switches (meal
3231    /// breaks, overnight), not thinking time (default 1800 = 30 min).
3232    pub max_gap_secs: Option<u64>,
3233    /// Start time (nanoseconds since Unix epoch)
3234    pub start_time: Option<i64>,
3235    /// End time (nanoseconds since Unix epoch)
3236    pub end_time: Option<i64>,
3237}
3238
3239/// Human response latency (#171): the gap between an assistant turn
3240/// ending and the next prompt of the same session and tool — how long
3241/// reading and thinking took. Small gaps mean the flow state is
3242/// intact; the hour-of-day view shows which windows you work in per
3243/// tool. Gaps beyond `max_gap_secs` (default 30 min) are excluded.
3244#[utoipa::path(
3245    get,
3246    path = "/api/genai/human_response_latency",
3247    params(HumanResponseLatencyQuery),
3248    responses(
3249        (status = 200, description = "Per-tool and per-hour human latency percentiles", body = otelite_core::api::HumanResponseLatencyResponse),
3250        (status = 400, description = "Invalid max_gap_secs", body = ErrorResponse),
3251        (status = 500, description = "Internal server error", body = ErrorResponse)
3252    ),
3253    tag = "genai"
3254)]
3255pub async fn get_human_response_latency(
3256    State(state): State<AppState>,
3257    Query(query): Query<HumanResponseLatencyQuery>,
3258) -> Result<Json<otelite_core::api::HumanResponseLatencyResponse>, (StatusCode, Json<ErrorResponse>)>
3259{
3260    use otelite_core::human_latency;
3261
3262    const DEFAULT_MAX_GAP_SECS: u64 = 1800;
3263    const MAX_MAX_GAP_SECS: u64 = 86_400;
3264    let max_gap_secs = query.max_gap_secs.unwrap_or(DEFAULT_MAX_GAP_SECS);
3265    if max_gap_secs == 0 || max_gap_secs > MAX_MAX_GAP_SECS {
3266        return Err((
3267            StatusCode::BAD_REQUEST,
3268            Json(ErrorResponse::bad_request(format!(
3269                "max_gap_secs must be between 1 and {MAX_MAX_GAP_SECS} seconds, got {max_gap_secs}"
3270            ))),
3271        ));
3272    }
3273
3274    let resp = state
3275        .storage
3276        .query_human_response_latency(query.start_time, query.end_time, max_gap_secs)
3277        .await
3278        .map_err(|e| {
3279            (
3280                StatusCode::INTERNAL_SERVER_ERROR,
3281                Json(ErrorResponse::storage_error(format!(
3282                    "query human_response_latency: {e}"
3283                ))),
3284            )
3285        })?;
3286
3287    let (by_tool, by_hour) = human_latency::summarize(&resp.rows);
3288    Ok(Json(otelite_core::api::HumanResponseLatencyResponse {
3289        by_tool,
3290        by_hour,
3291        filters_applied: Vec::new(),
3292    }))
3293}
3294
3295/// Session × model cross-tab: tokens and cost per (session_id, model) pair (#115).
3296#[utoipa::path(
3297    get,
3298    path = "/api/genai/session_model_breakdown",
3299    params(TimeRangeQuery),
3300    responses(
3301        (status = 200, description = "Session × model cross-tab", body = otelite_core::api::SessionModelBreakdown),
3302        (status = 500, description = "Internal server error", body = ErrorResponse)
3303    ),
3304    tag = "genai"
3305)]
3306pub async fn get_session_model_breakdown(
3307    State(state): State<AppState>,
3308    Query(query): Query<TimeRangeQuery>,
3309) -> Result<Json<otelite_core::api::SessionModelBreakdown>, (StatusCode, Json<ErrorResponse>)> {
3310    let mut response = state
3311        .storage
3312        .query_session_model_breakdown(query.start_time, query.end_time)
3313        .await
3314        .map_err(|e| {
3315            (
3316                StatusCode::INTERNAL_SERVER_ERROR,
3317                Json(ErrorResponse::storage_error(format!(
3318                    "query session_model_breakdown: {e}"
3319                ))),
3320            )
3321        })?;
3322    // Enrich each row with pricing cost from model identifier.
3323    let pricing_db = state.pricing.snapshot().await;
3324    for row in &mut response.rows {
3325        let usage = TokenUsage {
3326            input: row.input_tokens,
3327            output: row.output_tokens,
3328            cache_creation: 0,
3329            cache_read: 0,
3330        };
3331        let cr = pricing_db.db.compute_cost(Some(&row.model), usage, None);
3332        row.cost = cr.cost;
3333    }
3334    // Re-sort by cost desc (nulls last), then requests desc.
3335    response.rows.sort_by(|a, b| match (a.cost, b.cost) {
3336        (Some(ac), Some(bc)) => bc.partial_cmp(&ac).unwrap_or(std::cmp::Ordering::Equal),
3337        (Some(_), None) => std::cmp::Ordering::Less,
3338        (None, Some(_)) => std::cmp::Ordering::Greater,
3339        (None, None) => b.requests.cmp(&a.requests),
3340    });
3341    response.filters_applied = query.filters().applied(&[]);
3342    Ok(Json(response))
3343}
3344
3345/// Speed/effort attribute distribution across Claude Code LLM spans (#114).
3346#[utoipa::path(
3347    get,
3348    path = "/api/genai/speed_distribution",
3349    params(TimeRangeQuery),
3350    responses(
3351        (status = 200, description = "Speed/effort distribution by model", body = otelite_core::api::SpeedDistribution),
3352        (status = 500, description = "Internal server error", body = ErrorResponse)
3353    ),
3354    tag = "genai"
3355)]
3356pub async fn get_speed_distribution(
3357    State(state): State<AppState>,
3358    Query(query): Query<TimeRangeQuery>,
3359) -> Result<Json<otelite_core::api::SpeedDistribution>, (StatusCode, Json<ErrorResponse>)> {
3360    let mut response = state
3361        .storage
3362        .query_speed_distribution(query.start_time, query.end_time)
3363        .await
3364        .map_err(|e| {
3365            (
3366                StatusCode::INTERNAL_SERVER_ERROR,
3367                Json(ErrorResponse::storage_error(format!(
3368                    "query speed_distribution: {e}"
3369                ))),
3370            )
3371        })?;
3372    response.filters_applied = query.filters().applied(&[]);
3373    Ok(Json(response))
3374}
3375
3376/// Cross-tool TTFT comparison from span-level ttft_ms attribute.
3377#[utoipa::path(
3378    get,
3379    path = "/api/genai/cross_tool_ttft",
3380    params(TimeRangeQuery),
3381    responses(
3382        (status = 200, description = "TTFT statistics by tool and model", body = otelite_core::api::CrossToolTtftResponse),
3383        (status = 500, description = "Internal server error", body = ErrorResponse)
3384    ),
3385    tag = "genai"
3386)]
3387pub async fn get_cross_tool_ttft(
3388    State(state): State<AppState>,
3389    Query(query): Query<TimeRangeQuery>,
3390) -> Result<Json<otelite_core::api::CrossToolTtftResponse>, (StatusCode, Json<ErrorResponse>)> {
3391    let response = state
3392        .storage
3393        .query_cross_tool_ttft(query.start_time, query.end_time)
3394        .await
3395        .map_err(|e| {
3396            (
3397                StatusCode::INTERNAL_SERVER_ERROR,
3398                Json(ErrorResponse::storage_error(format!(
3399                    "query cross_tool_ttft: {e}"
3400                ))),
3401            )
3402        })?;
3403    Ok(Json(response))
3404}
3405
3406/// Codex hook overhead: total and average duration per hook event type.
3407#[utoipa::path(
3408    get,
3409    path = "/api/genai/hook_overhead",
3410    params(TimeRangeQuery),
3411    responses(
3412        (status = 200, description = "Hook overhead by event type", body = otelite_core::api::HookOverheadResponse),
3413        (status = 500, description = "Internal server error", body = ErrorResponse)
3414    ),
3415    tag = "genai"
3416)]
3417pub async fn get_hook_overhead(
3418    State(state): State<AppState>,
3419    Query(query): Query<TimeRangeQuery>,
3420) -> Result<Json<otelite_core::api::HookOverheadResponse>, (StatusCode, Json<ErrorResponse>)> {
3421    let response = state
3422        .storage
3423        .query_hook_overhead(query.start_time, query.end_time)
3424        .await
3425        .map_err(|e| {
3426            (
3427                StatusCode::INTERNAL_SERVER_ERROR,
3428                Json(ErrorResponse::storage_error(format!(
3429                    "query hook_overhead: {e}"
3430                ))),
3431            )
3432        })?;
3433    Ok(Json(response))
3434}
3435
3436/// Bob hook overhead (#167): the Codex hook-overhead mirror for Bob.
3437/// Returns an empty response until Bob emits
3438/// `bob.hooks.run.duration_ms` (upstream request tracked in #167).
3439#[utoipa::path(
3440    get,
3441    path = "/api/genai/bob_hook_overhead",
3442    params(TimeRangeQuery),
3443    responses(
3444        (status = 200, description = "Bob hook overhead by event type", body = otelite_core::api::HookOverheadResponse),
3445        (status = 500, description = "Internal server error", body = ErrorResponse)
3446    ),
3447    tag = "genai"
3448)]
3449pub async fn get_bob_hook_overhead(
3450    State(state): State<AppState>,
3451    Query(query): Query<TimeRangeQuery>,
3452) -> Result<Json<otelite_core::api::HookOverheadResponse>, (StatusCode, Json<ErrorResponse>)> {
3453    let response = state
3454        .storage
3455        .query_bob_hook_overhead(query.start_time, query.end_time)
3456        .await
3457        .map_err(|e| {
3458            (
3459                StatusCode::INTERNAL_SERVER_ERROR,
3460                Json(ErrorResponse::storage_error(format!(
3461                    "query bob_hook_overhead: {e}"
3462                ))),
3463            )
3464        })?;
3465    Ok(Json(response))
3466}
3467
3468/// Tool-switch overhead (#166): where switching tools mid-session costs
3469/// time — switch gaps and the cold-vs-warm TTFT comparison at each
3470/// tool boundary.
3471#[utoipa::path(
3472    get,
3473    path = "/api/genai/tool_switch_overhead",
3474    params(TimeRangeQuery),
3475    responses(
3476        (status = 200, description = "Tool switch counts, gaps, and cold/warm TTFT", body = otelite_core::api::ToolSwitchOverheadResponse),
3477        (status = 500, description = "Internal server error", body = ErrorResponse)
3478    ),
3479    tag = "genai"
3480)]
3481pub async fn get_tool_switch_overhead(
3482    State(state): State<AppState>,
3483    Query(query): Query<TimeRangeQuery>,
3484) -> Result<Json<otelite_core::api::ToolSwitchOverheadResponse>, (StatusCode, Json<ErrorResponse>)>
3485{
3486    let storage_resp = state
3487        .storage
3488        .query_tool_switch_storage(query.start_time, query.end_time)
3489        .await
3490        .map_err(|e| {
3491            (
3492                StatusCode::INTERNAL_SERVER_ERROR,
3493                Json(ErrorResponse::storage_error(format!(
3494                    "query tool_switch_overhead: {e}"
3495                ))),
3496            )
3497        })?;
3498    Ok(Json(otelite_core::tool_switch::analyze(&storage_resp.rows)))
3499}
3500
3501/// Tool failure rates from opencode.tool.duration.
3502#[utoipa::path(
3503    get,
3504    path = "/api/genai/tool_failure_rates",
3505    params(TimeRangeQuery),
3506    responses(
3507        (status = 200, description = "Tool failure rates sorted by failure count", body = otelite_core::api::ToolFailureRatesResponse),
3508        (status = 500, description = "Internal server error", body = ErrorResponse)
3509    ),
3510    tag = "genai"
3511)]
3512pub async fn get_tool_failure_rates(
3513    State(state): State<AppState>,
3514    Query(query): Query<TimeRangeQuery>,
3515) -> Result<Json<otelite_core::api::ToolFailureRatesResponse>, (StatusCode, Json<ErrorResponse>)> {
3516    let response = state
3517        .storage
3518        .query_tool_failure_rates(query.start_time, query.end_time)
3519        .await
3520        .map_err(|e| {
3521            (
3522                StatusCode::INTERNAL_SERVER_ERROR,
3523                Json(ErrorResponse::storage_error(format!(
3524                    "query tool_failure_rates: {e}"
3525                ))),
3526            )
3527        })?;
3528    Ok(Json(response))
3529}
3530
3531/// Price a daily tool mix response: fill each row's `total_cost_usd` from
3532/// the per-model breakdown and return the (day, tool) -> cost rollup for
3533/// callers that need it directly (#177). Unpriced models contribute
3534/// nothing — a row with no pricing data keeps a `None` cost, never a
3535/// fabricated zero (#179).
3536fn price_daily_tool_mix(
3537    pricing: &crate::pricing_cache::PricingState,
3538    response: &mut otelite_core::api::DailyToolMixResponse,
3539) -> std::collections::HashMap<(String, String), f64> {
3540    let mut cost: std::collections::HashMap<(String, String), f64> =
3541        std::collections::HashMap::new();
3542    for m in &response.model_rows {
3543        let usage = TokenUsage {
3544            input: m.input_tokens,
3545            output: m.output_tokens,
3546            cache_creation: m.cache_creation_tokens,
3547            cache_read: m.cache_read_tokens,
3548        };
3549        let result = pricing.db.compute_cost(Some(m.model.as_str()), usage, None);
3550        if let Some(c) = result.cost {
3551            *cost.entry((m.day.clone(), m.tool.clone())).or_insert(0.0) += c;
3552        }
3553    }
3554    for r in &mut response.rows {
3555        if let Some(c) = cost.get(&(r.day.clone(), r.tool.clone())) {
3556            r.total_cost_usd = Some(*c);
3557        }
3558    }
3559    cost
3560}
3561
3562/// Daily tool activity mix (claude_code / opencode / codex datapoints per day).
3563#[utoipa::path(
3564    get,
3565    path = "/api/genai/daily_tool_mix",
3566    params(TimeRangeQuery),
3567    responses(
3568        (status = 200, description = "Datapoints per tool per calendar day", body = otelite_core::api::DailyToolMixResponse),
3569        (status = 500, description = "Internal server error", body = ErrorResponse)
3570    ),
3571    tag = "genai"
3572)]
3573pub async fn get_daily_tool_mix(
3574    State(state): State<AppState>,
3575    Query(query): Query<TimeRangeQuery>,
3576) -> Result<Json<otelite_core::api::DailyToolMixResponse>, (StatusCode, Json<ErrorResponse>)> {
3577    let mut response = state
3578        .storage
3579        .query_daily_tool_mix(query.start_time, query.end_time)
3580        .await
3581        .map_err(|e| {
3582            (
3583                StatusCode::INTERNAL_SERVER_ERROR,
3584                Json(ErrorResponse::storage_error(format!(
3585                    "query daily_tool_mix: {e}"
3586                ))),
3587            )
3588        })?;
3589
3590    // Price the per-model breakdown and roll cost up to (day, tool) (#179).
3591    let pricing = state.pricing.snapshot().await;
3592    price_daily_tool_mix(&pricing, &mut response);
3593
3594    Ok(Json(response))
3595}
3596
3597/// Git output per tool per day: commits, PRs, lines of code, with the
3598/// daily tool mix's cost joined on (day, tool) for cost-per-commit (#177).
3599#[utoipa::path(
3600    get,
3601    path = "/api/genai/productivity",
3602    params(TimeRangeQuery),
3603    responses(
3604        (status = 200, description = "Commits, PRs and lines of code per tool per calendar day", body = otelite_core::api::ProductivityResponse),
3605        (status = 500, description = "Internal server error", body = ErrorResponse)
3606    ),
3607    tag = "genai"
3608)]
3609pub async fn get_productivity(
3610    State(state): State<AppState>,
3611    Query(query): Query<TimeRangeQuery>,
3612) -> Result<Json<otelite_core::api::ProductivityResponse>, (StatusCode, Json<ErrorResponse>)> {
3613    let mut response = state
3614        .storage
3615        .query_productivity_summary(query.start_time, query.end_time)
3616        .await
3617        .map_err(|e| {
3618            (
3619                StatusCode::INTERNAL_SERVER_ERROR,
3620                Json(ErrorResponse::storage_error(format!(
3621                    "query productivity_summary: {e}"
3622                ))),
3623            )
3624        })?;
3625
3626    // Join the daily tool mix's priced cost rollup onto (day, tool) — the
3627    // existing cost data, priced exactly like /api/genai/daily_tool_mix.
3628    let mix = state
3629        .storage
3630        .query_daily_tool_mix(query.start_time, query.end_time)
3631        .await
3632        .map_err(|e| {
3633            (
3634                StatusCode::INTERNAL_SERVER_ERROR,
3635                Json(ErrorResponse::storage_error(format!(
3636                    "query daily_tool_mix for productivity cost: {e}"
3637                ))),
3638            )
3639        })?;
3640    let pricing = state.pricing.snapshot().await;
3641    let cost = {
3642        let mut mix = mix;
3643        price_daily_tool_mix(&pricing, &mut mix)
3644    };
3645    for r in &mut response.rows {
3646        if let Some(c) = cost.get(&(r.day.clone(), r.tool.clone())) {
3647            r.cost_usd = Some(*c);
3648            r.cost_per_commit_usd = if r.commits > 0 {
3649                Some(*c / r.commits as f64)
3650            } else {
3651                None
3652            };
3653        }
3654    }
3655
3656    Ok(Json(response))
3657}
3658
3659/// Codex skill injection activity.
3660#[utoipa::path(
3661    get,
3662    path = "/api/genai/skill_activity",
3663    params(TimeRangeQuery),
3664    responses(
3665        (status = 200, description = "Skill injection counts by skill name and invoke type", body = otelite_core::api::SkillActivityResponse),
3666        (status = 500, description = "Internal server error", body = ErrorResponse)
3667    ),
3668    tag = "genai"
3669)]
3670pub async fn get_skill_activity(
3671    State(state): State<AppState>,
3672    Query(query): Query<TimeRangeQuery>,
3673) -> Result<Json<otelite_core::api::SkillActivityResponse>, (StatusCode, Json<ErrorResponse>)> {
3674    let response = state
3675        .storage
3676        .query_skill_activity(query.start_time, query.end_time)
3677        .await
3678        .map_err(|e| {
3679            (
3680                StatusCode::INTERNAL_SERVER_ERROR,
3681                Json(ErrorResponse::storage_error(format!(
3682                    "query skill_activity: {e}"
3683                ))),
3684            )
3685        })?;
3686    Ok(Json(response))
3687}
3688
3689#[utoipa::path(
3690    get,
3691    path = "/api/genai/session_quality_summary",
3692    params(TimeRangeQuery),
3693    responses(
3694        (status = 200, description = "Aggregate session quality counts: clean / degraded / errored", body = otelite_core::api::SessionQualitySummary),
3695        (status = 500, description = "Internal server error", body = ErrorResponse)
3696    ),
3697    tag = "genai"
3698)]
3699pub async fn get_session_quality_summary(
3700    State(state): State<AppState>,
3701    Query(query): Query<TimeRangeQuery>,
3702) -> Result<Json<otelite_core::api::SessionQualitySummary>, (StatusCode, Json<ErrorResponse>)> {
3703    let response = state
3704        .storage
3705        .query_session_quality_summary(query.start_time, query.end_time)
3706        .await
3707        .map_err(|e| {
3708            (
3709                StatusCode::INTERNAL_SERVER_ERROR,
3710                Json(ErrorResponse::storage_error(format!(
3711                    "query session_quality_summary: {e}"
3712                ))),
3713            )
3714        })?;
3715    Ok(Json(response))
3716}
3717
3718#[utoipa::path(
3719    get,
3720    path = "/api/genai/skill_outcomes",
3721    params(TimeRangeQuery),
3722    responses(
3723        (status = 200, description = "Token comparison for sessions with vs without each skill", body = otelite_core::api::SkillOutcomesResponse),
3724        (status = 500, description = "Internal server error", body = ErrorResponse)
3725    ),
3726    tag = "genai"
3727)]
3728pub async fn get_skill_outcomes(
3729    State(state): State<AppState>,
3730    Query(query): Query<TimeRangeQuery>,
3731) -> Result<Json<otelite_core::api::SkillOutcomesResponse>, (StatusCode, Json<ErrorResponse>)> {
3732    let response = state
3733        .storage
3734        .query_skill_outcomes(query.start_time, query.end_time)
3735        .await
3736        .map_err(|e| {
3737            (
3738                StatusCode::INTERNAL_SERVER_ERROR,
3739                Json(ErrorResponse::storage_error(format!(
3740                    "query skill_outcomes: {e}"
3741                ))),
3742            )
3743        })?;
3744    Ok(Json(response))
3745}
3746
3747#[utoipa::path(
3748    get,
3749    path = "/api/genai/model_selection_heatmap",
3750    params(TimeRangeQuery),
3751    responses(
3752        (status = 200, description = "Model-selection heatmap: (role, tool, model) → request count and token share", body = otelite_core::api::ModelSelectionHeatmapResponse),
3753        (status = 500, description = "Internal server error", body = ErrorResponse)
3754    ),
3755    tag = "genai"
3756)]
3757pub async fn get_model_selection_heatmap(
3758    State(state): State<AppState>,
3759    Query(query): Query<TimeRangeQuery>,
3760) -> Result<Json<otelite_core::api::ModelSelectionHeatmapResponse>, (StatusCode, Json<ErrorResponse>)>
3761{
3762    let response = state
3763        .storage
3764        .query_model_selection_heatmap(query.start_time, query.end_time)
3765        .await
3766        .map_err(|e| {
3767            (
3768                StatusCode::INTERNAL_SERVER_ERROR,
3769                Json(ErrorResponse::storage_error(format!(
3770                    "query model_selection_heatmap: {e}"
3771                ))),
3772            )
3773        })?;
3774    Ok(Json(response))
3775}
3776
3777/// Query parameters for the `GET /api/genai/recent_errors` endpoint.
3778#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
3779pub struct RecentErrorsQuery {
3780    /// Start time (nanoseconds since Unix epoch)
3781    pub start_time: Option<i64>,
3782    /// End time (nanoseconds since Unix epoch)
3783    pub end_time: Option<i64>,
3784    /// Filter by tool harness label (`claude_code`, `opencode`, `codex`, `pi`, …)
3785    pub tool: Option<String>,
3786    /// Maximum number of rows to return (default 50, max 200)
3787    pub limit: Option<usize>,
3788}
3789
3790#[utoipa::path(
3791    get,
3792    path = "/api/genai/recent_errors",
3793    params(RecentErrorsQuery),
3794    responses(
3795        (status = 200, description = "Most recent error events from spans and logs", body = otelite_core::api::RecentErrorsResponse),
3796        (status = 500, description = "Internal server error", body = ErrorResponse)
3797    ),
3798    tag = "genai"
3799)]
3800pub async fn get_recent_errors(
3801    State(state): State<AppState>,
3802    Query(query): Query<RecentErrorsQuery>,
3803) -> Result<Json<otelite_core::api::RecentErrorsResponse>, (StatusCode, Json<ErrorResponse>)> {
3804    let response = state
3805        .storage
3806        .query_recent_errors(query.start_time, query.end_time, query.tool, query.limit)
3807        .await
3808        .map_err(|e| {
3809            (
3810                StatusCode::INTERNAL_SERVER_ERROR,
3811                Json(ErrorResponse::storage_error(format!(
3812                    "query recent_errors: {e}"
3813                ))),
3814            )
3815        })?;
3816    Ok(Json(response))
3817}
3818
3819genai_filter_impl!(TokenUsageQuery);
3820genai_filter_impl!(CostSeriesQuery);
3821genai_filter_impl!(FinishReasonsQuery);
3822genai_filter_impl!(LatencyQuery);
3823genai_filter_impl!(ErrorRateQuery);
3824genai_filter_impl!(ModelAnalyticsQuery);
3825genai_filter_impl!(CacheQuery);
3826genai_filter_impl!(ModelTimeSeriesQuery);
3827genai_filter_impl!(TopSpansQuery);
3828genai_filter_impl!(TopGroupQuery);
3829genai_filter_impl!(LatencyPercentileQuery);
3830genai_filter_impl!(DistributionQuery);
3831genai_filter_impl!(ToolUsageQuery);
3832genai_filter_impl!(RetryStatsQuery);
3833genai_filter_impl!(RecentRetriesQuery);
3834genai_filter_impl!(RetrievalStatsQuery);
3835genai_filter_impl!(TimeRangeQuery);
3836genai_filter_impl!(TimeSeriesQuery);
3837
3838#[cfg(test)]
3839mod tests {
3840    use super::*;
3841
3842    #[test]
3843    fn by_model_flag_accepts_one_and_true_only() {
3844        assert!(by_model_enabled(Some("1")));
3845        assert!(by_model_enabled(Some("true")));
3846        assert!(!by_model_enabled(Some("0")));
3847        assert!(!by_model_enabled(Some("yes")));
3848        assert!(!by_model_enabled(Some("2")));
3849        assert!(!by_model_enabled(None));
3850    }
3851}