systemprompt_api/services/gateway/stream_tap/
mod.rs1mod accumulator;
8
9#[cfg(feature = "test-api")]
10pub mod test_api {
11 pub use super::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
12}
13
14use std::pin::Pin;
15use std::sync::{Arc, Mutex};
16use std::task::{Context, Poll};
17
18use axum::body::Body;
19use bytes::Bytes;
20use futures_util::stream::{BoxStream, Stream};
21use systemprompt_database::DbPool;
22use systemprompt_identifiers::AiRequestId;
23
24use self::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
25use super::audit::GatewayAudit;
26use super::policy::GatewayPolicySpec;
27use super::protocol::canonical_response::CanonicalEvent;
28use super::protocol::inbound::InboundAdapter;
29use super::protocol::outbound::anthropic::streaming::SseDecoder;
30use super::quota;
31use super::service::run_response_safety_scan;
32use super::signature_cache::ThoughtSignatureCache;
33
34#[derive(Debug)]
37pub struct TapFinalizeCtx {
38 pub db: DbPool,
39 pub repos: crate::services::gateway::GatewayRepositories,
40 pub policy: GatewayPolicySpec,
41 pub ai_request_id: AiRequestId,
42}
43
44pub fn tap(
45 upstream: BoxStream<'static, Result<CanonicalEvent, String>>,
46 inbound: Arc<dyn InboundAdapter>,
47 request_model: String,
48 audit: Arc<GatewayAudit>,
49 finalize_ctx: TapFinalizeCtx,
50) -> Body {
51 let state = Arc::new(Mutex::new(TapState::default()));
52 let tapped = TappedStream {
53 inner: upstream,
54 state: Arc::clone(&state),
55 inbound,
56 request_model,
57 audit,
58 finalize_ctx: Some(finalize_ctx),
59 };
60 Body::from_stream(tapped)
61}
62
63pub fn tap_raw(
64 upstream: BoxStream<'static, Result<Bytes, String>>,
65 audit: Arc<GatewayAudit>,
66 finalize_ctx: TapFinalizeCtx,
67) -> Body {
68 Body::from_stream(RawTappedStream {
69 inner: upstream,
70 state: Arc::new(Mutex::new(TapState::default())),
71 decoder: SseDecoder::default(),
72 audit,
73 finalize_ctx: Some(finalize_ctx),
74 })
75}
76
77struct RawTappedStream {
78 inner: BoxStream<'static, Result<Bytes, String>>,
79 state: Arc<Mutex<TapState>>,
80 decoder: SseDecoder,
81 audit: Arc<GatewayAudit>,
82 finalize_ctx: Option<TapFinalizeCtx>,
83}
84
85impl RawTappedStream {
86 fn take_summary(&mut self) -> Option<(Summary, TapFinalizeCtx)> {
87 let ctx = self.finalize_ctx.take()?;
88 self.state.lock().ok().and_then(|mut s| {
89 if s.finalized {
90 return None;
91 }
92 s.finalized = true;
93 Some((extract_summary(&mut s), ctx))
94 })
95 }
96}
97
98impl Stream for RawTappedStream {
99 type Item = Result<Bytes, std::io::Error>;
100
101 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
102 match self.inner.as_mut().poll_next(cx) {
103 Poll::Pending => Poll::Pending,
104 Poll::Ready(None) => {
105 if let Some((summary, ctx)) = self.take_summary() {
106 finalize(Arc::clone(&self.audit), summary, ctx, "eof");
107 }
108 Poll::Ready(None)
109 },
110 Poll::Ready(Some(Err(e))) => {
111 if let Ok(mut s) = self.state.lock() {
112 s.error = Some(e.clone());
113 }
114 Poll::Ready(Some(Err(std::io::Error::new(
115 std::io::ErrorKind::BrokenPipe,
116 e,
117 ))))
118 },
119 Poll::Ready(Some(Ok(bytes))) => {
120 let events = self.decoder.push(&bytes);
121 if let Ok(mut s) = self.state.lock() {
122 for event in &events {
123 accumulate_event(&mut s, event);
124 }
125 s.final_bytes.extend_from_slice(&bytes);
126 }
127 Poll::Ready(Some(Ok(bytes)))
128 },
129 }
130 }
131}
132
133impl Drop for RawTappedStream {
134 fn drop(&mut self) {
135 let Some((summary, ctx)) = self.take_summary() else {
136 return;
137 };
138 finalize(Arc::clone(&self.audit), summary, ctx, "drop");
139 }
140}
141
142struct TappedStream {
143 inner: BoxStream<'static, Result<CanonicalEvent, String>>,
144 state: Arc<Mutex<TapState>>,
145 inbound: Arc<dyn InboundAdapter>,
146 request_model: String,
147 audit: Arc<GatewayAudit>,
148 finalize_ctx: Option<TapFinalizeCtx>,
149}
150
151impl Stream for TappedStream {
152 type Item = Result<Bytes, std::io::Error>;
153
154 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
155 loop {
156 match self.inner.as_mut().poll_next(cx) {
157 Poll::Pending => return Poll::Pending,
158 Poll::Ready(None) => {
159 return self.finalize_on_eof();
160 },
161 Poll::Ready(Some(Err(e))) => {
162 if let Ok(mut s) = self.state.lock() {
163 s.error = Some(e.clone());
164 }
165 let err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, e);
166 return Poll::Ready(Some(Err(err)));
167 },
168 Poll::Ready(Some(Ok(event))) => {
169 let terminal = matches!(
170 event,
171 CanonicalEvent::ContentBlockStop { .. }
172 | CanonicalEvent::MessageStop { .. }
173 );
174 let snap = self.state.lock().map_or(None, |mut s| {
175 accumulate_event(&mut s, &event);
176 terminal.then(|| snapshot(&s))
177 });
178 let rendered = snap
179 .as_ref()
180 .and_then(|snapshot| {
181 self.inbound.render_terminal_event(
182 &event,
183 snapshot,
184 &self.request_model,
185 )
186 })
187 .or_else(|| self.inbound.render_event(&event, &self.request_model));
188 if let Some(bytes) = rendered {
189 if let Ok(mut s) = self.state.lock() {
190 s.final_bytes.extend_from_slice(&bytes);
191 }
192 return Poll::Ready(Some(Ok(bytes)));
193 }
194 },
195 }
196 }
197 }
198}
199
200impl TappedStream {
201 fn take_summary(&mut self) -> Option<(Summary, TapFinalizeCtx)> {
202 let ctx = self.finalize_ctx.take()?;
203 self.state.lock().ok().and_then(|mut s| {
204 if s.finalized {
205 return None;
206 }
207 s.finalized = true;
208 Some((extract_summary(&mut s), ctx))
209 })
210 }
211
212 fn finalize_on_eof(&mut self) -> Poll<Option<Result<Bytes, std::io::Error>>> {
213 let Some((summary, ctx)) = self.take_summary() else {
214 return Poll::Ready(None);
215 };
216 finalize(Arc::clone(&self.audit), summary, ctx, "eof");
217 Poll::Ready(None)
218 }
219}
220
221impl Drop for TappedStream {
222 fn drop(&mut self) {
223 let Some((summary, ctx)) = self.take_summary() else {
224 return;
225 };
226 finalize(Arc::clone(&self.audit), summary, ctx, "drop");
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum FinalizeDecision {
232 Fail(&'static str),
233 Complete { cost_capture_miss: bool },
234}
235
236pub const fn classify(
237 error: Option<&str>,
238 saw_stop: bool,
239 has_content: bool,
240 has_usage: bool,
241) -> FinalizeDecision {
242 if error.is_some() {
243 return FinalizeDecision::Fail("upstream stream error");
244 }
245 if !saw_stop {
246 return FinalizeDecision::Fail(if has_content {
247 "stream ended without stop event"
248 } else {
249 "empty upstream stream"
250 });
251 }
252 FinalizeDecision::Complete {
253 cost_capture_miss: has_content && !has_usage,
254 }
255}
256
257fn finalize(audit: Arc<GatewayAudit>, summary: Summary, ctx: TapFinalizeCtx, origin: &'static str) {
258 if let Some(conversation) = &audit.ctx.gateway_conversation_id {
259 ThoughtSignatureCache::global().store_from_response(conversation, &summary.response);
260 }
261 tokio::spawn(async move {
262 if let Some(model) = summary.served_model.as_deref() {
263 audit.set_served_model(model).await;
264 }
265
266 let has_content = !summary.final_bytes.is_empty();
267 let has_usage = summary.saw_usage_delta
268 && (summary.usage.input_tokens > 0 || summary.usage.output_tokens > 0);
269 match classify(
270 summary.error.as_deref(),
271 summary.saw_stop,
272 has_content,
273 has_usage,
274 ) {
275 FinalizeDecision::Fail(reason) => {
276 let msg = summary.error.as_deref().unwrap_or(reason);
277 if let Err(e) = audit.fail(msg).await {
278 tracing::warn!(origin, error = %e, "stream audit fail failed");
279 }
280 },
281 FinalizeDecision::Complete { cost_capture_miss } => {
282 if cost_capture_miss {
283 tracing::warn!(
284 origin,
285 "stream completed with content but zero usage: cost capture miss"
286 );
287 }
288 let cost_microdollars = match audit
289 .complete(
290 summary.usage,
291 summary.tool_calls,
292 &summary.response,
293 &summary.final_bytes,
294 )
295 .await
296 {
297 Ok(cost) => cost,
298 Err(e) => {
299 tracing::warn!(origin, error = %e, "stream audit complete failed");
300 0
301 },
302 };
303 quota::post_update_tokens(
304 &ctx.db,
305 &ctx.repos.quota_buckets,
306 quota::PostUpdateParams {
307 user_id: &audit.ctx.user_id,
308 windows: &ctx.policy.quota_windows,
309 input_tokens: summary.usage.input_tokens,
310 output_tokens: summary.usage.output_tokens,
311 cost_microdollars,
312 },
313 )
314 .await;
315 run_response_safety_scan(
316 &ctx.repos.safety_findings,
317 &ctx.ai_request_id,
318 &summary.response,
319 &ctx.policy.safety,
320 )
321 .await;
322 },
323 }
324 });
325}