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::run_response_safety_scan;
17use super::super::signature_cache::ThoughtSignatureCache;
18use super::TapFinalizeCtx;
19use super::accumulator::Summary;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum FinalizeDecision {
23    Fail(FailCause),
24    Complete { cost_capture_miss: bool },
25}
26
27/// Why a tapped stream ended without a usable response.
28///
29/// Both land as `failed` in the audit row, so they are separated here:
30/// `Upstream` is the provider failing mid-stream, `Truncated` is the stream
31/// stopping without a terminal event — typically the client hanging up.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum FailCause {
34    Upstream,
35    Truncated { has_content: bool },
36}
37
38impl FailCause {
39    pub const fn reason(self) -> &'static str {
40        match self {
41            Self::Upstream => "upstream stream error",
42            Self::Truncated { has_content: true } => "stream ended without stop event",
43            Self::Truncated { has_content: false } => "empty upstream stream",
44        }
45    }
46
47    // Why: a mid-stream failure carries no upstream HTTP status — the provider
48    // already sent 200 and the SSE error frame has none — so the status is
49    // derived, matching `map_upstream_error`'s bad-gateway fallback. A client
50    // that hung up is 499, not an upstream fault.
51    const fn status(self) -> u16 {
52        match self {
53            Self::Upstream => 502,
54            Self::Truncated { .. } => 499,
55        }
56    }
57}
58
59pub const fn classify(
60    error: Option<&str>,
61    saw_stop: bool,
62    has_content: bool,
63    has_usage: bool,
64) -> FinalizeDecision {
65    if error.is_some() {
66        return FinalizeDecision::Fail(FailCause::Upstream);
67    }
68    if !saw_stop {
69        return FinalizeDecision::Fail(FailCause::Truncated { has_content });
70    }
71    FinalizeDecision::Complete {
72        cost_capture_miss: has_content && !has_usage,
73    }
74}
75
76pub(super) fn finalize(
77    audit: Arc<GatewayAudit>,
78    summary: Summary,
79    ctx: TapFinalizeCtx,
80    origin: &'static str,
81) {
82    tokio::spawn(async move {
83        capture_signatures(&ctx, &audit, &summary).await;
84        if let Some(model) = summary.served_model.as_deref() {
85            audit.set_served_model(model).await;
86        }
87
88        let has_content = !summary.final_bytes.is_empty();
89        let has_usage = summary.saw_usage_delta
90            && (summary.usage.input_tokens > 0 || summary.usage.output_tokens > 0);
91        match classify(
92            summary.error.as_deref(),
93            summary.saw_stop,
94            has_content,
95            has_usage,
96        ) {
97            FinalizeDecision::Fail(cause) => {
98                let msg = summary.error.as_deref().unwrap_or_else(|| cause.reason());
99                if let Err(e) = audit.fail(msg).await {
100                    tracing::warn!(origin, error = %e, "stream audit fail failed");
101                }
102                log_terminal(&audit, cause.status(), Some(msg));
103            },
104            FinalizeDecision::Complete { cost_capture_miss } => {
105                if cost_capture_miss {
106                    tracing::warn!(
107                        origin,
108                        "stream completed with content but zero usage: cost capture miss"
109                    );
110                }
111                let cost_microdollars = match audit
112                    .complete(
113                        summary.usage,
114                        summary.tool_calls,
115                        &summary.response,
116                        &summary.final_bytes,
117                    )
118                    .await
119                {
120                    Ok(cost) => cost,
121                    Err(e) => {
122                        tracing::warn!(origin, error = %e, "stream audit complete failed");
123                        0
124                    },
125                };
126                quota::post_update_tokens(
127                    &ctx.db,
128                    &ctx.repos.quota_buckets,
129                    quota::PostUpdateParams {
130                        user_id: &audit.ctx.user_id,
131                        windows: &ctx.policy.quota_windows,
132                        input_tokens: summary.usage.input_tokens,
133                        output_tokens: summary.usage.output_tokens,
134                        cost_microdollars,
135                    },
136                )
137                .await;
138                run_response_safety_scan(
139                    &ctx.repos.safety_findings,
140                    &ctx.ai_request_id,
141                    &summary.response,
142                    &ctx.policy.safety,
143                )
144                .await;
145                log_terminal(&audit, 200, None);
146            },
147        }
148    });
149}
150
151fn log_terminal(audit: &GatewayAudit, status: u16, error: Option<&str>) {
152    let Some(access) = audit.ctx.access_log.as_ref() else {
153        return;
154    };
155    log_gateway_terminal(TerminalOutcome {
156        access,
157        status,
158        actor: terminal_actor(audit),
159        error,
160    });
161}
162
163fn terminal_actor(audit: &GatewayAudit) -> Option<LogActor> {
164    if let (Some(session), Some(trace)) =
165        (audit.ctx.session_id.as_ref(), audit.ctx.trace_id.as_ref())
166    {
167        return Some(LogActor::new(
168            audit.ctx.user_id.clone(),
169            session.clone(),
170            trace.clone(),
171        ));
172    }
173    LogActor::platform(TraceId::system()).ok()
174}
175
176async fn capture_signatures(ctx: &TapFinalizeCtx, audit: &GatewayAudit, summary: &Summary) {
177    match &audit.ctx.gateway_conversation_id {
178        Some(conversation) => {
179            ctx.repos
180                .thought_signatures
181                .store_from_response(conversation, &summary.response)
182                .await;
183        },
184        None => {
185            ThoughtSignatureCache::note_uncacheable_response(
186                &summary.response,
187                "no_conversation_id",
188            );
189        },
190    }
191}