Skip to main content

systemprompt_cli/commands/infrastructure/logs/request/
mod.rs

1//! `infra logs request` subcommands for inspecting AI provider requests.
2//!
3//! Exposes [`RequestCommands`] (list, show, stats) and the row types
4//! ([`RequestListRow`], [`RequestShowOutput`]) returned to the renderer.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9mod list;
10mod show;
11mod stats;
12
13use anyhow::Result;
14use clap::Subcommand;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18use super::types::{MessageRow, ToolCallRow};
19use crate::context::CommandContext;
20use crate::shared::{CommandOutput, render_result};
21use serde_json::Value as JsonValue;
22use systemprompt_identifiers::{AiRequestId, UserId};
23use systemprompt_models::artifacts::{Column, ColumnType, NoticeLine, TableArtifact};
24
25pub use stats::{RequestStatsOutput, build_request_stats};
26
27const REQUEST_ID_DISPLAY_WIDTH: usize = 12;
28
29fn request_list_columns() -> Vec<Column> {
30    vec![
31        Column::new("request_id", ColumnType::String).with_width(REQUEST_ID_DISPLAY_WIDTH),
32        Column::new("timestamp", ColumnType::String),
33        Column::new("user_id", ColumnType::String),
34        Column::new("actor", ColumnType::String),
35        Column::new("client", ColumnType::String),
36        Column::new("attestation", ColumnType::String),
37        Column::new("provider", ColumnType::String),
38        Column::new("model", ColumnType::String),
39        Column::new("tokens", ColumnType::String),
40        Column::new("cost", ColumnType::String),
41        Column::new("latency_ms", ColumnType::Number),
42        Column::new("status", ColumnType::String),
43        Column::new("finish", ColumnType::String),
44    ]
45}
46
47#[must_use]
48pub fn build_request_list(rows: &[RequestListRow]) -> CommandOutput {
49    if rows.is_empty() {
50        return CommandOutput::message(vec![NoticeLine::new("info", "No AI requests found")]);
51    }
52    let items: Vec<JsonValue> = rows
53        .iter()
54        .map(|row| serde_json::to_value(row).unwrap_or(JsonValue::Null))
55        .collect();
56    CommandOutput::table_artifact(TableArtifact::new(request_list_columns()).with_rows(items))
57        .with_title("AI Requests")
58}
59
60#[must_use]
61pub fn build_request_show(detail: &RequestShowOutput) -> CommandOutput {
62    CommandOutput::card_value("AI Request Details", detail)
63}
64
65#[must_use]
66pub fn request_show_not_found(request_id: &str) -> CommandOutput {
67    CommandOutput::message(vec![
68        NoticeLine::new("warning", format!("AI request not found: {request_id}")),
69        NoticeLine::new(
70            "info",
71            "Tip: Use 'systemprompt infra logs request list' to see recent requests",
72        ),
73    ])
74}
75
76#[derive(Debug, Subcommand)]
77pub enum RequestCommands {
78    #[command(
79        about = "Operational list of recent AI requests. For dashboard metrics (time range, model filter, CSV export), use `analytics requests list`",
80        after_help = "EXAMPLES:\n  systemprompt infra logs request list\n  systemprompt infra \
81                      logs request list --model gpt-4 --since 1h"
82    )]
83    List(list::ListArgs),
84
85    #[command(
86        about = "Quick single-request view by request id (messages, linked MCP calls, status/error)",
87        after_help = "EXAMPLES:\n  systemprompt infra logs request show abc123\n  systemprompt \
88                      infra logs request show abc123 --messages --tools"
89    )]
90    Show(show::ShowArgs),
91
92    #[command(
93        about = "Operational request aggregate with by-provider / by-model breakdown. For range/model-filtered dashboards with export, use `analytics requests stats`",
94        after_help = "EXAMPLES:\n  systemprompt infra logs request stats\n  systemprompt infra \
95                      logs request stats --since 24h"
96    )]
97    Stats(stats::StatsArgs),
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct RequestListRow {
102    pub request_id: AiRequestId,
103    pub timestamp: String,
104    pub cursor: String,
105    pub user_id: UserId,
106    pub actor: String,
107    pub client: String,
108    pub attestation: String,
109    pub provider: String,
110    pub model: String,
111    pub tokens: String,
112    pub cost: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub latency_ms: Option<i64>,
115    pub status: String,
116    pub finish: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
120pub struct RequestShowOutput {
121    pub request_id: AiRequestId,
122    pub user_id: UserId,
123    pub actor_kind: String,
124    pub actor_id: String,
125    pub client: String,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub client_evidence: Option<ClientEvidenceOutput>,
128    pub provider: Option<String>,
129    pub model: Option<String>,
130    pub input_tokens: i32,
131    pub output_tokens: i32,
132    pub cost_dollars: f64,
133    pub latency_ms: i64,
134    pub status: String,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub error_message: Option<String>,
137    pub messages: Vec<MessageRow>,
138    pub linked_mcp_calls: Vec<ToolCallRow>,
139}
140
141/// Only the evidence the wire presented is rendered; a field the request did
142/// not carry is omitted rather than shown empty.
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
144pub struct ClientEvidenceOutput {
145    pub kind_source: String,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub attested_host: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub declared_client: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub native_marker: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub ua_product: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub ua_version: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub sdk_lang: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub sdk_package_version: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub sdk_runtime: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub sdk_runtime_version: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub sdk_os: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub sdk_arch: Option<String>,
168}
169
170// Why: lint-ok: field-copy-from — the two types differ in wire shape, not in
171// fields. This one omits absent evidence (`skip_serializing_if` on eleven
172// fields) and derives `JsonSchema` for the CLI's output contract; the runtime
173// model does neither, and `systemprompt-runtime` carries no schemars
174// dependency, so collapsing them would push a CLI rendering concern down a
175// layer to get there.
176impl From<systemprompt_runtime::AiRequestClientEvidence> for ClientEvidenceOutput {
177    fn from(evidence: systemprompt_runtime::AiRequestClientEvidence) -> Self {
178        Self {
179            kind_source: evidence.kind_source,
180            attested_host: evidence.attested_host,
181            declared_client: evidence.declared_client,
182            native_marker: evidence.native_marker,
183            ua_product: evidence.ua_product,
184            ua_version: evidence.ua_version,
185            sdk_lang: evidence.sdk_lang,
186            sdk_package_version: evidence.sdk_package_version,
187            sdk_runtime: evidence.sdk_runtime,
188            sdk_runtime_version: evidence.sdk_runtime_version,
189            sdk_os: evidence.sdk_os,
190            sdk_arch: evidence.sdk_arch,
191        }
192    }
193}
194
195pub async fn execute(command: RequestCommands, ctx: &CommandContext) -> Result<()> {
196    match command {
197        RequestCommands::List(args) => {
198            let result = list::execute(args, ctx).await?;
199            render_result(&result, &ctx.cli);
200            Ok(())
201        },
202        RequestCommands::Show(args) => {
203            let result = show::execute(args, ctx).await?;
204            render_result(&result, &ctx.cli);
205            Ok(())
206        },
207        RequestCommands::Stats(args) => stats::execute(args, ctx).await,
208    }
209}