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