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, McpToolName, TaskId, TraceId};
13use systemprompt_models::text::truncate_with_ellipsis;
14use systemprompt_runtime::{AuditPage, TraceQueryService, TraceRepository};
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 finish_reason: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub error_message: Option<String>,
97 pub provider: Option<String>,
98 pub model: Option<String>,
99 pub requested_model: Option<String>,
100 pub input_tokens: i32,
101 pub output_tokens: i32,
102 pub cache_read_tokens: i32,
103 pub cache_creation_tokens: i32,
104 #[serde(default)]
105 pub reasoning_tokens: i32,
106 pub cost_dollars: f64,
107 pub latency_ms: i64,
108 pub task_id: Option<TaskId>,
109 pub trace_id: Option<TraceId>,
110 pub message_count: i64,
111 pub tool_call_count: i64,
112 pub offset: i64,
113 pub has_more: bool,
114 pub messages: Vec<MessageRow>,
115 pub tool_calls: Vec<AuditToolCall>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
119pub struct AuditToolCall {
120 pub tool_name: McpToolName,
121 pub tool_input: String,
122 pub sequence: i32,
123}
124
125crate::define_pool_command!(AuditArgs => (), with_config);
126
127async fn execute_with_pool_inner(
128 args: AuditArgs,
129 pool: &Arc<sqlx::PgPool>,
130 config: &CliConfig,
131) -> Result<()> {
132 let service = TraceQueryService::new(TraceRepository::new(Arc::clone(pool)));
133
134 let row = service.find_ai_request_for_audit(&args.id).await?;
135
136 let Some(row) = row else {
137 render_result(¬_found_output(&args.id), config);
138 return Ok(());
139 };
140
141 let request_id = row.id;
142 let page = args.page();
143 let (message_count, tool_call_count) = tokio::try_join!(
144 service.count_audit_messages(&request_id),
145 service.count_audit_tool_calls(&request_id),
146 )?;
147 let messages = if args.messages {
148 service.list_audit_messages(&request_id, page).await?
149 } else {
150 Vec::new()
151 };
152 let tool_calls = if args.tools {
153 service.list_audit_tool_calls(&request_id, page).await?
154 } else {
155 Vec::new()
156 };
157 let page_end = page.offset.saturating_add(page.limit);
158 let has_more = page.limit > 0
159 && ((args.messages && page_end < message_count)
160 || (args.tools && page_end < tool_call_count));
161
162 let output = AuditOutput {
163 request_id,
164 status: row.status,
165 finish_reason: row.finish_reason,
166 error_message: row.error_message,
167 provider: row.provider,
168 model: row.model,
169 requested_model: row.requested_model,
170 input_tokens: row.input_tokens.unwrap_or(0),
171 output_tokens: row.output_tokens.unwrap_or(0),
172 cache_read_tokens: row.cache_read_tokens.unwrap_or(0),
173 reasoning_tokens: row.reasoning_tokens.unwrap_or(0),
174 cache_creation_tokens: row.cache_creation_tokens.unwrap_or(0),
175 cost_dollars: row.cost_microdollars as f64 / 1_000_000.0,
176 latency_ms: i64::from(row.latency_ms.unwrap_or(0)),
177 task_id: row.task_id,
178 trace_id: row.trace_id,
179 message_count,
180 tool_call_count,
181 offset: page.offset,
182 has_more,
183 messages: messages
184 .into_iter()
185 .map(|m| MessageRow {
186 sequence: m.sequence_number,
187 role: m.role,
188 content: args.bounded(m.content),
189 })
190 .collect(),
191 tool_calls: tool_calls
192 .into_iter()
193 .map(|t| AuditToolCall {
194 tool_name: t.tool_name,
195 tool_input: args.bounded(t.tool_input),
196 sequence: t.sequence_number,
197 })
198 .collect(),
199 };
200
201 render_result(&build_audit(&output), config);
202
203 Ok(())
204}
205
206#[must_use]
207pub fn build_audit(output: &AuditOutput) -> CommandOutput {
208 let title = if output.status == "completed" {
209 "AI Request Audit".to_owned()
210 } else {
211 format!("AI Request Audit — {}", output.status.to_uppercase())
212 };
213 CommandOutput::card_value(title, output)
214}
215
216#[must_use]
217pub fn not_found_output(id: &str) -> CommandOutput {
218 use systemprompt_models::artifacts::NoticeLine;
219 CommandOutput::message(vec![
220 NoticeLine::new("warning", format!("No AI request found for: {id}")),
221 NoticeLine::new(
222 "info",
223 "Tip: Use 'systemprompt infra logs request list' to see recent requests",
224 ),
225 NoticeLine::new(
226 "info",
227 "Use 'systemprompt infra logs trace list' to see recent traces",
228 ),
229 ])
230}