systemprompt_cli/commands/infrastructure/logs/
audit.rs1use std::sync::Arc;
7
8use anyhow::Result;
9use clap::Args;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::{AiRequestId, TaskId, TraceId};
13use systemprompt_models::text::truncate_with_ellipsis;
14use systemprompt_runtime::{AuditPage, TraceQueryService};
15
16use super::types::MessageRow;
17use crate::CliConfig;
18use crate::shared::{CommandOutput, render_result};
19
20#[derive(Debug, Args)]
21pub struct AuditArgs {
22 #[arg(help = "AI request ID, task ID, or trace ID")]
23 pub id: String,
24
25 #[arg(
26 long,
27 short = 'm',
28 help = "Include the conversation messages (default: counts only)"
29 )]
30 pub messages: bool,
31
32 #[arg(
33 long,
34 short = 't',
35 help = "Include the tool calls (default: counts only)"
36 )]
37 pub tools: bool,
38
39 #[arg(
40 long,
41 default_value = "0",
42 allow_negative_numbers = true,
43 value_parser = clap::value_parser!(i64).range(0..),
44 help = "Skip this many messages / tool calls before the page"
45 )]
46 pub offset: i64,
47
48 #[arg(
49 long,
50 short = 'n',
51 default_value = "20",
52 allow_negative_numbers = true,
53 value_parser = clap::value_parser!(i64).range(0..),
54 help = "Maximum messages / tool calls per page (0 = all)"
55 )]
56 pub limit: i64,
57
58 #[arg(
59 long,
60 value_name = "CHARS",
61 default_value = "0",
62 help = "Truncate each message body and tool input to this many characters (0 = full)"
63 )]
64 pub max_content: usize,
65}
66
67impl AuditArgs {
68 const fn page(&self) -> AuditPage {
69 AuditPage {
70 offset: self.offset,
71 limit: self.limit,
72 }
73 }
74
75 fn bounded(&self, text: String) -> String {
76 if self.max_content == 0 {
77 text
78 } else {
79 truncate_with_ellipsis(&text, self.max_content)
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct AuditOutput {
91 pub request_id: AiRequestId,
92 pub status: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub error_message: Option<String>,
95 pub provider: Option<String>,
96 pub model: Option<String>,
97 pub requested_model: Option<String>,
98 pub input_tokens: i32,
99 pub output_tokens: i32,
100 pub cache_read_tokens: i32,
101 pub cache_creation_tokens: i32,
102 #[serde(default)]
103 pub reasoning_tokens: i32,
104 pub cost_dollars: f64,
105 pub latency_ms: i64,
106 pub task_id: Option<TaskId>,
107 pub trace_id: Option<TraceId>,
108 pub message_count: i64,
109 pub tool_call_count: i64,
110 pub offset: i64,
111 pub has_more: bool,
112 pub messages: Vec<MessageRow>,
113 pub tool_calls: Vec<AuditToolCall>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct AuditToolCall {
118 pub tool_name: String,
119 pub tool_input: String,
120 pub sequence: i32,
121}
122
123crate::define_pool_command!(AuditArgs => (), with_config);
124
125async fn execute_with_pool_inner(
126 args: AuditArgs,
127 pool: &Arc<sqlx::PgPool>,
128 config: &CliConfig,
129) -> Result<()> {
130 let service = TraceQueryService::new(Arc::clone(pool));
131
132 let row = service.find_ai_request_for_audit(&args.id).await?;
133
134 let Some(row) = row else {
135 render_result(¬_found_output(&args.id), config);
136 return Ok(());
137 };
138
139 let request_id = row.id;
140 let page = args.page();
141 let (message_count, tool_call_count) = tokio::try_join!(
142 service.count_audit_messages(&request_id),
143 service.count_audit_tool_calls(&request_id),
144 )?;
145 let messages = if args.messages {
146 service.list_audit_messages(&request_id, page).await?
147 } else {
148 Vec::new()
149 };
150 let tool_calls = if args.tools {
151 service.list_audit_tool_calls(&request_id, page).await?
152 } else {
153 Vec::new()
154 };
155 let page_end = page.offset.saturating_add(page.limit);
156 let has_more = page.limit > 0
157 && ((args.messages && page_end < message_count)
158 || (args.tools && page_end < tool_call_count));
159
160 let output = AuditOutput {
161 request_id,
162 status: row.status,
163 error_message: row.error_message,
164 provider: row.provider,
165 model: row.model,
166 requested_model: row.requested_model,
167 input_tokens: row.input_tokens.unwrap_or(0),
168 output_tokens: row.output_tokens.unwrap_or(0),
169 cache_read_tokens: row.cache_read_tokens.unwrap_or(0),
170 reasoning_tokens: row.reasoning_tokens.unwrap_or(0),
171 cache_creation_tokens: row.cache_creation_tokens.unwrap_or(0),
172 cost_dollars: row.cost_microdollars as f64 / 1_000_000.0,
173 latency_ms: i64::from(row.latency_ms.unwrap_or(0)),
174 task_id: row.task_id,
175 trace_id: row.trace_id.map(TraceId::new),
176 message_count,
177 tool_call_count,
178 offset: page.offset,
179 has_more,
180 messages: messages
181 .into_iter()
182 .map(|m| MessageRow {
183 sequence: m.sequence_number,
184 role: m.role,
185 content: args.bounded(m.content),
186 })
187 .collect(),
188 tool_calls: tool_calls
189 .into_iter()
190 .map(|t| AuditToolCall {
191 tool_name: t.tool_name,
192 tool_input: args.bounded(t.tool_input),
193 sequence: t.sequence_number,
194 })
195 .collect(),
196 };
197
198 render_result(&build_audit(&output), config);
199
200 Ok(())
201}
202
203#[must_use]
204pub fn build_audit(output: &AuditOutput) -> CommandOutput {
205 let title = if output.status == "completed" {
206 "AI Request Audit".to_owned()
207 } else {
208 format!("AI Request Audit — {}", output.status.to_uppercase())
209 };
210 CommandOutput::card_value(title, output)
211}
212
213#[must_use]
214pub fn not_found_output(id: &str) -> CommandOutput {
215 use systemprompt_models::artifacts::NoticeLine;
216 CommandOutput::message(vec![
217 NoticeLine::new("warning", format!("No AI request found for: {id}")),
218 NoticeLine::new(
219 "info",
220 "Tip: Use 'systemprompt infra logs request list' to see recent requests",
221 ),
222 NoticeLine::new(
223 "info",
224 "Use 'systemprompt infra logs trace list' to see recent traces",
225 ),
226 ])
227}