use crate::commands::usage::parse_time_range;
use crate::config::{Config, OutputFormat};
use crate::error::{Error, Result};
use chrono::{DateTime, Local};
use clap::Args;
use comfy_table::{
modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, ContentArrangement, Table,
};
use otelite_client::ApiClient;
use otelite_core::api::TopSpan;
#[derive(Args, Debug)]
#[command(
about = "Recent LLM request health: status, model, tokens, TTFT, trace-id",
after_help = "Examples:\n otelite llm\n otelite llm --since 1h --limit 50\n otelite llm --model claude-sonnet-4-5 --status error\n otelite llm --trace <trace-id>"
)]
pub struct LlmCommand {
#[arg(long, default_value = "1h")]
pub since: String,
#[arg(long, default_value = "30")]
pub limit: u64,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub session: Option<String>,
#[arg(long)]
pub status: Option<String>,
#[arg(long)]
pub trace: Option<String>,
}
impl LlmCommand {
pub async fn execute(self, config: Config) -> Result<()> {
let client = ApiClient::new(config.endpoint.clone(), config.timeout)
.map_err(|e| Error::ApiError(format!("Failed to create API client: {}", e)))?;
if let Some(ref trace_id) = self.trace {
return drill_trace(&client, &config, trace_id).await;
}
let (start_ns, end_ns) = parse_time_range(&self.since)?;
let mut params: Vec<(&str, String)> = vec![
("start_time", start_ns.to_string()),
("end_time", end_ns.to_string()),
("limit", self.limit.to_string()),
];
if let Some(ref m) = self.model {
params.push(("model", m.clone()));
}
if let Some(ref s) = self.session {
params.push(("session", s.clone()));
}
let resp = client
.fetch_top_spans(params)
.await
.map_err(|e| Error::ApiError(format!("Failed to fetch LLM requests: {}", e)))?;
let spans: Vec<&TopSpan> = resp
.iter()
.filter(|s| match self.status.as_deref() {
Some("ok") => s
.finish_reason
.as_deref()
.map(|r| r == "end_turn" || r == "stop")
.unwrap_or(false),
Some("error") => s
.finish_reason
.as_deref()
.map(|r| r != "end_turn" && r != "stop")
.unwrap_or(true),
_ => true,
})
.collect();
match config.format {
OutputFormat::Json | OutputFormat::JsonCompact => {
let json = if matches!(config.format, OutputFormat::JsonCompact) {
serde_json::to_string(&spans)
} else {
serde_json::to_string_pretty(&spans)
};
println!(
"{}",
json.map_err(|e| Error::ApiError(format!("JSON error: {}", e)))?
);
},
OutputFormat::Pretty => {
display_llm_table(&spans, &self.since);
},
}
Ok(())
}
}
fn display_llm_table(spans: &[&TopSpan], since: &str) {
if spans.is_empty() {
println!("LLM Requests (last {}): none", since);
return;
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.apply_modifier(UTF8_ROUND_CORNERS)
.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("Time").fg(Color::Cyan),
Cell::new("Model").fg(Color::Cyan),
Cell::new("In").fg(Color::Cyan),
Cell::new("Cache-R").fg(Color::Cyan),
Cell::new("Out").fg(Color::Cyan),
Cell::new("Dur s").fg(Color::Cyan),
Cell::new("Cost").fg(Color::Cyan),
Cell::new("Status").fg(Color::Cyan),
Cell::new("Trace ID").fg(Color::Cyan),
]);
for s in spans {
let time_str = DateTime::from_timestamp(s.start_time / 1_000_000_000, 0)
.map(|dt| dt.with_timezone(&Local).format("%H:%M:%S").to_string())
.unwrap_or_else(|| "?".to_string());
let model = s
.model
.as_deref()
.unwrap_or("?")
.rsplit('/')
.next()
.unwrap_or("?");
let model = if model.len() > 28 {
format!("…{}", &model[model.len() - 27..])
} else {
model.to_string()
};
let dur_s = s.duration as f64 / 1_000_000_000.0;
let reason = s.finish_reason.as_deref().unwrap_or("");
let (status_cell, is_ok) = if reason == "end_turn" || reason == "stop" {
(Cell::new("✓ ok").fg(Color::Green), true)
} else if reason.is_empty() {
(Cell::new("?"), true) } else {
(Cell::new(format!("✗ {}", reason)).fg(Color::Red), false)
};
let _ = is_ok;
let cost_str = s
.cost
.map(|c| {
if c < 0.001 {
format!("{:.5}", c)
} else if c < 0.01 {
format!("{:.4}", c)
} else {
format!("${:.3}", c)
}
})
.unwrap_or_else(|| "—".to_string());
let trace_short = if s.trace_id.len() > 12 {
format!("{}…", &s.trace_id[..12])
} else {
s.trace_id.clone()
};
table.add_row(vec![
Cell::new(time_str),
Cell::new(model),
Cell::new(format_number(s.input_tokens)),
Cell::new(format_number(s.cache_read_tokens)),
Cell::new(format_number(s.output_tokens)),
Cell::new(format!("{:.1}", dur_s)),
Cell::new(cost_str),
status_cell,
Cell::new(trace_short),
]);
}
println!("LLM Requests (last {}):", since);
println!("{}", table);
println!("Tip: otelite llm --trace <trace-id> to drill into a request");
}
async fn drill_trace(client: &ApiClient, config: &Config, trace_id: &str) -> Result<()> {
let detail = client
.fetch_trace_by_id(trace_id)
.await
.map_err(|e| Error::ApiError(format!("Failed to fetch trace {}: {}", trace_id, e)))?;
match config.format {
OutputFormat::Json | OutputFormat::JsonCompact => {
let json = if matches!(config.format, OutputFormat::JsonCompact) {
serde_json::to_string(&detail)
} else {
serde_json::to_string_pretty(&detail)
};
println!(
"{}",
json.map_err(|e| Error::ApiError(format!("JSON error: {}", e)))?
);
},
OutputFormat::Pretty => {
println!("Trace: {}", trace_id);
println!("Spans ({}):", detail.spans.len());
for span in &detail.spans {
let dur_ms = span.duration / 1_000_000;
let indent = if span.parent_span_id.is_some() {
" └─ "
} else {
" "
};
let status = if span.status.code == "Error" {
"ERROR"
} else {
"ok"
};
println!(
"{}{} ({}) {}ms [{}]",
indent,
span.name,
&span.span_id[..8],
dur_ms,
status
);
for (k, v) in &span.attributes {
if k.starts_with("gen_ai.") || k.starts_with("llm.") {
println!(" {}: {}", k, v);
}
}
}
},
}
Ok(())
}
fn format_number(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_number_thousands() {
assert_eq!(format_number(1500), "1.5k");
}
#[test]
fn test_format_number_millions() {
assert_eq!(format_number(2_500_000), "2.5M");
}
#[test]
fn test_format_number_small() {
assert_eq!(format_number(42), "42");
}
}