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