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