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                // Why: the provider bills what it streamed before the break,
133                // so a truncated stream settles with the usage the tap saw.
134                let partial = summary.saw_usage_delta.then_some(summary.usage);
135                if let Err(e) = audit.fail_with_usage(msg, partial).await {
136                    tracing::warn!(origin, error = %e, "stream audit fail failed");
137                }
138                log_terminal(&audit, cause.status(), Some(msg));
139            },
140            FinalizeDecision::Complete { cost_capture_miss } => {
141                if cost_capture_miss {
142                    tracing::warn!(
143                        origin,
144                        "stream completed with content but zero usage: cost capture miss"
145                    );
146                }
147                let cost_microdollars = match audit
148                    .complete(
149                        summary.usage,
150                        summary.tool_calls,
151                        &summary.response,
152                        &summary.final_bytes,
153                    )
154                    .await
155                {
156                    Ok(cost) => cost,
157                    Err(e) => {
158                        tracing::warn!(origin, error = %e, "stream audit complete failed");
159                        0
160                    },
161                };
162                let accounting = quota::post_update_tokens(
163                    &ctx.db,
164                    &ctx.repos.quota_buckets,
165                    quota::PostUpdateParams {
166                        user_id: &audit.ctx.user_id,
167                        windows: &ctx.policy.quota_windows,
168                        input_tokens: summary.usage.input_tokens,
169                        output_tokens: summary.usage.output_tokens,
170                        cost_microdollars,
171                    },
172                )
173                .await;
174                record_accounting_outcome(&audit, ctx.quota_fault_mode, accounting).await;
175                run_response_safety_scan(
176                    &ctx.repos.safety_findings,
177                    &ctx.ai_request_id,
178                    &summary.response,
179                    &ctx.policy.safety,
180                )
181                .await;
182                log_terminal(&audit, 200, None);
183            },
184        }
185    });
186}
187
188pub(in crate::services::gateway) fn log_terminal(
189    audit: &GatewayAudit,
190    status: u16,
191    error: Option<&str>,
192) {
193    let Some(access) = audit.ctx.access_log.as_ref() else {
194        return;
195    };
196    log_gateway_terminal(TerminalOutcome {
197        access,
198        status,
199        actor: terminal_actor(audit),
200        error,
201    });
202}
203
204fn terminal_actor(audit: &GatewayAudit) -> Option<LogActor> {
205    if let (Some(session), Some(trace)) =
206        (audit.ctx.session_id.as_ref(), audit.ctx.trace_id.as_ref())
207    {
208        return Some(LogActor::new(
209            audit.ctx.user_id.clone(),
210            session.clone(),
211            trace.clone(),
212        ));
213    }
214    LogActor::platform(TraceId::system()).ok()
215}
216
217async fn capture_signatures(ctx: &TapFinalizeCtx, audit: &GatewayAudit, summary: &Summary) {
218    match &audit.ctx.gateway_conversation_id {
219        Some(conversation) => {
220            ctx.repos
221                .thought_signatures
222                .store_from_response(&audit.ctx.user_id, conversation, &summary.response)
223                .await;
224        },
225        None => {
226            ThoughtSignatureCache::note_uncacheable_response(
227                &summary.response,
228                "no_conversation_id",
229            );
230        },
231    }
232}