systemprompt_api/services/gateway/stream_tap/
finalize.rs1use 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#[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 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}