Skip to main content

systemprompt_api/services/gateway/stream_tap/
finalize.rs

1//! Terminal accounting for a tapped stream: classifies how the stream ended
2//! and spawns the audit, quota, and response-scan completion work.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use std::sync::Arc;
8
9use systemprompt_identifiers::TraceId;
10use systemprompt_logging::LogActor;
11
12use crate::routes::gateway::{TerminalOutcome, log_gateway_terminal};
13
14use super::super::audit::GatewayAudit;
15use super::super::quota;
16use super::super::service::finalize::record_accounting_outcome;
17use super::super::service::run_response_safety_scan;
18use super::super::signature_cache::ThoughtSignatureCache;
19use super::TapFinalizeCtx;
20use super::accumulator::Summary;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FinalizeDecision {
24    Fail(FailCause),
25    Complete { cost_capture_miss: bool },
26}
27
28/// Why a tapped stream ended without a usable response.
29///
30/// All of these land as `failed` in the audit row, so they are separated
31/// here: `Upstream` is the provider failing mid-stream, `Truncated` is the
32/// stream stopping without a terminal event. `client_gone` says who stopped
33/// it — the consumer dropped the body before the stop event (the client hung
34/// up, status 499) or upstream closed the stream early (status 502).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FailCause {
37    Upstream,
38    Truncated {
39        has_content: bool,
40        client_gone: bool,
41    },
42}
43
44impl FailCause {
45    pub const fn reason(self) -> &'static str {
46        match self {
47            Self::Upstream => "upstream stream error",
48            Self::Truncated {
49                client_gone: true, ..
50            } => "client disconnected before stop event",
51            Self::Truncated {
52                has_content: true,
53                client_gone: false,
54            } => "upstream stream ended without stop event",
55            Self::Truncated {
56                has_content: false,
57                client_gone: false,
58            } => "empty upstream stream",
59        }
60    }
61
62    // Why: SSE errors arrive after the upstream HTTP status has already been sent.
63    const fn status(self) -> u16 {
64        match self {
65            Self::Upstream
66            | Self::Truncated {
67                client_gone: false, ..
68            } => 502,
69            Self::Truncated {
70                client_gone: true, ..
71            } => 499,
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ClientConnection {
78    Connected,
79    Disconnected,
80}
81
82pub const fn classify(
83    error: Option<&str>,
84    saw_stop: bool,
85    has_content: bool,
86    has_usage: bool,
87    connection: ClientConnection,
88) -> FinalizeDecision {
89    if error.is_some() {
90        return FinalizeDecision::Fail(FailCause::Upstream);
91    }
92    if !saw_stop {
93        return FinalizeDecision::Fail(FailCause::Truncated {
94            has_content,
95            client_gone: matches!(connection, ClientConnection::Disconnected),
96        });
97    }
98    FinalizeDecision::Complete {
99        cost_capture_miss: has_content && !has_usage,
100    }
101}
102
103pub(super) fn finalize(
104    audit: Arc<GatewayAudit>,
105    summary: Summary,
106    ctx: TapFinalizeCtx,
107    origin: &'static str,
108) {
109    audit.mark_upstream_end();
110    tokio::spawn(async move {
111        capture_signatures(&ctx, &audit, &summary).await;
112        if let Some(model) = summary.served_model.as_deref() {
113            audit.set_served_model(model).await;
114        }
115
116        let has_content = !summary.final_bytes.is_empty();
117        let has_usage = summary.saw_usage_delta
118            && (summary.usage.input_tokens > 0 || summary.usage.output_tokens > 0);
119        match classify(
120            summary.error.as_deref(),
121            summary.saw_stop,
122            has_content,
123            has_usage,
124            if origin == "drop" {
125                ClientConnection::Disconnected
126            } else {
127                ClientConnection::Connected
128            },
129        ) {
130            FinalizeDecision::Fail(cause) => {
131                let msg = summary.error.as_deref().unwrap_or_else(|| cause.reason());
132                if let Err(e) = audit.fail(msg).await {
133                    tracing::warn!(origin, error = %e, "stream audit fail failed");
134                }
135                log_terminal(&audit, cause.status(), Some(msg));
136            },
137            FinalizeDecision::Complete { cost_capture_miss } => {
138                if cost_capture_miss {
139                    tracing::warn!(
140                        origin,
141                        "stream completed with content but zero usage: cost capture miss"
142                    );
143                }
144                let cost_microdollars = match audit
145                    .complete(
146                        summary.usage,
147                        summary.tool_calls,
148                        &summary.response,
149                        &summary.final_bytes,
150                    )
151                    .await
152                {
153                    Ok(cost) => cost,
154                    Err(e) => {
155                        tracing::warn!(origin, error = %e, "stream audit complete failed");
156                        0
157                    },
158                };
159                let accounting = quota::post_update_tokens(
160                    &ctx.db,
161                    &ctx.repos.quota_buckets,
162                    quota::PostUpdateParams {
163                        user_id: &audit.ctx.user_id,
164                        windows: &ctx.policy.quota_windows,
165                        input_tokens: summary.usage.input_tokens,
166                        output_tokens: summary.usage.output_tokens,
167                        cost_microdollars,
168                    },
169                )
170                .await;
171                record_accounting_outcome(&audit, ctx.quota_fault_mode, accounting).await;
172                run_response_safety_scan(
173                    &ctx.repos.safety_findings,
174                    &ctx.ai_request_id,
175                    &summary.response,
176                    &ctx.policy.safety,
177                )
178                .await;
179                log_terminal(&audit, 200, None);
180            },
181        }
182    });
183}
184
185pub(in crate::services::gateway) fn log_terminal(
186    audit: &GatewayAudit,
187    status: u16,
188    error: Option<&str>,
189) {
190    let Some(access) = audit.ctx.access_log.as_ref() else {
191        return;
192    };
193    log_gateway_terminal(TerminalOutcome {
194        access,
195        status,
196        actor: terminal_actor(audit),
197        error,
198    });
199}
200
201fn terminal_actor(audit: &GatewayAudit) -> Option<LogActor> {
202    if let (Some(session), Some(trace)) =
203        (audit.ctx.session_id.as_ref(), audit.ctx.trace_id.as_ref())
204    {
205        return Some(LogActor::new(
206            audit.ctx.user_id.clone(),
207            session.clone(),
208            trace.clone(),
209        ));
210    }
211    LogActor::platform(TraceId::system()).ok()
212}
213
214async fn capture_signatures(ctx: &TapFinalizeCtx, audit: &GatewayAudit, summary: &Summary) {
215    match &audit.ctx.gateway_conversation_id {
216        Some(conversation) => {
217            ctx.repos
218                .thought_signatures
219                .store_from_response(&audit.ctx.user_id, conversation, &summary.response)
220                .await;
221        },
222        None => {
223            ThoughtSignatureCache::note_uncacheable_response(
224                &summary.response,
225                "no_conversation_id",
226            );
227        },
228    }
229}