1use 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
20macro_rules! genai_filter_impl {
27 ($t:ident) => {
28 impl $t {
29 fn filters(&self) -> GenAiFilters {
32 GenAiFilters {
33 agent: self.agent.clone(),
34 model: self.model.clone(),
35 models: None,
38 provider: self.provider.clone(),
39 project: self.project.clone(),
40 session: self.session.clone(),
41 }
42 }
43 }
44 };
45}
46
47#[derive(Debug, Serialize, utoipa::ToSchema)]
50pub struct GenAiItemsResponse {
51 pub items: serde_json::Value,
53 pub filters_applied: Vec<String>,
55}
56
57fn 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
78fn 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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
98pub struct TokenUsageQuery {
99 pub start_time: Option<i64>,
101 pub end_time: Option<i64>,
103 pub agent: Option<String>,
105 pub model: Option<String>,
107 pub provider: Option<String>,
109 pub project: Option<String>,
111 pub session: Option<String>,
113}
114
115#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
158pub struct CostSeriesQuery {
159 pub start_time: Option<i64>,
161 pub end_time: Option<i64>,
163 pub bucket: Option<i64>,
165 pub agent: Option<String>,
167 pub model: Option<String>,
169 pub provider: Option<String>,
171 pub project: Option<String>,
173 pub session: Option<String>,
175}
176
177#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
241pub struct ProjectionQuery {
242 pub end_time: Option<i64>,
245}
246
247#[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#[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 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#[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 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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
413pub struct TimeInToolQuery {
414 pub start_time: Option<i64>,
416 pub end_time: Option<i64>,
418 pub max_gap_secs: Option<u64>,
421}
422
423#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
471pub struct TopSpansQuery {
472 pub start_time: Option<i64>,
474 pub end_time: Option<i64>,
476 pub limit: Option<usize>,
478 #[serde(default)]
481 pub sort_by: TopSpanSort,
482 #[serde(default)]
484 pub truncated_only: bool,
485 pub agent: Option<String>,
487 pub model: Option<String>,
489 pub provider: Option<String>,
491 pub project: Option<String>,
493 pub session: Option<String>,
495}
496
497#[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 pub agent: Option<String>,
505 pub model: Option<String>,
507 pub provider: Option<String>,
509 pub project: Option<String>,
511 pub session: Option<String>,
513}
514
515#[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#[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#[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 sessions.truncate(limit);
684
685 Ok(Json(otelite_core::api::ContextCompositionResponse {
686 sessions,
687 }))
688}
689
690#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
740pub struct FinishReasonsQuery {
741 pub start_time: Option<i64>,
743 pub end_time: Option<i64>,
745 pub agent: Option<String>,
747 pub model: Option<String>,
749 pub provider: Option<String>,
751 pub project: Option<String>,
753 pub session: Option<String>,
755}
756
757#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
806pub struct LatencyQuery {
807 pub start_time: Option<i64>,
809 pub end_time: Option<i64>,
811 pub agent: Option<String>,
813 pub model: Option<String>,
815 pub provider: Option<String>,
817 pub project: Option<String>,
819 pub session: Option<String>,
821}
822
823#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
825pub struct LatencyPercentileQuery {
826 pub start_time: Option<i64>,
828 pub end_time: Option<i64>,
830 pub bucket_secs: Option<u64>,
833 pub metrics: Option<String>,
835 pub calendar_day: Option<String>,
841 pub timezone: Option<String>,
845 pub agent: Option<String>,
847 pub model: Option<String>,
849 pub provider: Option<String>,
851 pub project: Option<String>,
853 pub session: Option<String>,
855}
856
857#[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#[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 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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1034pub struct DistributionQuery {
1035 pub metric: String,
1037 pub start_time: Option<i64>,
1039 pub end_time: Option<i64>,
1041 pub buckets: Option<usize>,
1043 pub scale: Option<String>,
1045 pub agent: Option<String>,
1047 pub model: Option<String>,
1049 pub provider: Option<String>,
1051 pub project: Option<String>,
1053 pub session: Option<String>,
1055}
1056
1057#[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 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#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1199pub struct ModelPerformanceQueryParams {
1200 pub start_time: i64,
1202 pub end_time: i64,
1204 pub rolling_ns: Option<i64>,
1207 pub model: Option<String>,
1209 pub provider: Option<String>,
1211 pub timezone: Option<String>,
1213}
1214
1215#[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 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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1327pub struct ErrorRateQuery {
1328 pub start_time: Option<i64>,
1330 pub end_time: Option<i64>,
1332 pub agent: Option<String>,
1334 pub model: Option<String>,
1336 pub provider: Option<String>,
1338 pub project: Option<String>,
1340 pub session: Option<String>,
1342}
1343
1344#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1390pub struct ToolUsageQuery {
1391 pub start_time: Option<i64>,
1393 pub end_time: Option<i64>,
1395 pub limit: Option<usize>,
1397 pub agent: Option<String>,
1399 pub model: Option<String>,
1401 pub provider: Option<String>,
1403 pub project: Option<String>,
1405 pub session: Option<String>,
1407}
1408
1409#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1455pub struct RecentRetriesQuery {
1456 pub start_time: Option<i64>,
1458 pub end_time: Option<i64>,
1460 pub limit: Option<usize>,
1462 pub agent: Option<String>,
1464 pub model: Option<String>,
1466 pub provider: Option<String>,
1468 pub project: Option<String>,
1470 pub session: Option<String>,
1472}
1473
1474#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1523pub struct RetryStatsQuery {
1524 pub start_time: Option<i64>,
1526 pub end_time: Option<i64>,
1528 pub agent: Option<String>,
1530 pub model: Option<String>,
1532 pub provider: Option<String>,
1534 pub project: Option<String>,
1536 pub session: Option<String>,
1538}
1539
1540#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1577pub struct RetrievalStatsQuery {
1578 pub start_time: Option<i64>,
1580 pub end_time: Option<i64>,
1582 pub limit: Option<usize>,
1584 pub agent: Option<String>,
1586 pub model: Option<String>,
1588 pub provider: Option<String>,
1590 pub project: Option<String>,
1592 pub session: Option<String>,
1594}
1595
1596#[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#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
1640pub struct PricingMetadata {
1641 pub source: &'static str,
1644 pub entry_count: usize,
1646 pub last_fetched_unix_ms: Option<i64>,
1648 pub last_failed_unix_ms: Option<i64>,
1650 pub fallback_last_verified: &'static str,
1653 pub source_url: &'static str,
1655 pub license: &'static str,
1657 pub disclaimer: &'static str,
1659 pub filters_applied: Vec<String>,
1661}
1662
1663#[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 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#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1729pub struct ModelAnalyticsQuery {
1730 pub start_time: Option<i64>,
1731 pub end_time: Option<i64>,
1732 pub agent: Option<String>,
1734 pub model: Option<String>,
1736 pub provider: Option<String>,
1738 pub project: Option<String>,
1740 pub session: Option<String>,
1742}
1743
1744#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1746pub struct CacheQuery {
1747 pub start_time: Option<i64>,
1748 pub end_time: Option<i64>,
1749 pub agent: Option<String>,
1753 pub model: Option<String>,
1755 pub provider: Option<String>,
1757 pub project: Option<String>,
1759 pub session: Option<String>,
1761 pub by_model: Option<String>,
1766 pub bucket_secs: Option<u64>,
1769}
1770
1771#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1773pub struct TimeSeriesQuery {
1774 pub start_time: Option<i64>,
1775 pub end_time: Option<i64>,
1776 pub bucket_secs: Option<u64>,
1778 pub span_filter: Option<String>,
1780 pub agent: Option<String>,
1782 pub model: Option<String>,
1784 pub provider: Option<String>,
1786 pub project: Option<String>,
1788 pub session: Option<String>,
1790}
1791
1792#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1794pub struct ModelTimeSeriesQuery {
1795 pub start_time: Option<i64>,
1796 pub end_time: Option<i64>,
1797 pub bucket_secs: Option<u64>,
1799 pub agent: Option<String>,
1802 pub model: Option<String>,
1804 pub provider: Option<String>,
1806 pub project: Option<String>,
1808 pub session: Option<String>,
1810 pub span_filter: Option<String>,
1812}
1813
1814#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
1816pub struct TimeRangeQuery {
1817 pub start_time: Option<i64>,
1818 pub end_time: Option<i64>,
1819 pub agent: Option<String>,
1821 pub model: Option<String>,
1823 pub provider: Option<String>,
1825 pub project: Option<String>,
1827 pub session: Option<String>,
1829}
1830
1831#[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
1874fn by_model_enabled(v: Option<&str>) -> bool {
1876 matches!(v, Some("1") | Some("true"))
1877}
1878
1879#[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 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 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 Ok(Json(serde_json::json!({
1987 "items": items,
1988 "filters_applied": filters.applied(&FILTER_DIMENSIONS),
1989 })))
1990}
1991
1992#[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 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 response.filters_applied = filters.applied(&[]);
2041
2042 Ok(Json(response))
2043}
2044
2045#[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#[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#[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 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#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
3154pub struct RareToolSessionsQuery {
3155 pub rare_session_threshold: Option<u64>,
3159 pub start_time: Option<i64>,
3161 pub end_time: Option<i64>,
3163}
3164
3165#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
3229pub struct HumanResponseLatencyQuery {
3230 pub max_gap_secs: Option<u64>,
3233 pub start_time: Option<i64>,
3235 pub end_time: Option<i64>,
3237}
3238
3239#[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#[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 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 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#[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#[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#[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#[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#[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#[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
3531fn 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#[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 let pricing = state.pricing.snapshot().await;
3592 price_daily_tool_mix(&pricing, &mut response);
3593
3594 Ok(Json(response))
3595}
3596
3597#[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 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#[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#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
3779pub struct RecentErrorsQuery {
3780 pub start_time: Option<i64>,
3782 pub end_time: Option<i64>,
3784 pub tool: Option<String>,
3786 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}