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::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#[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 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}