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 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}