Skip to main content

systemprompt_api/services/gateway/stream_tap/
mod.rs

1//! Streaming response tap: re-renders upstream canonical events to the inbound
2//! wire format while accumulating a full response snapshot for the audit sink.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7mod 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/// Shared by the streaming and buffered completion tasks so both debit quota
35/// and run the response-phase safety scan identically.
36#[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    // Why: providers signal the end of a message more than once (Anthropic's
151    // message_delta + message_stop, OpenAI's finish_reason chunk + [DONE]);
152    // only the first may drive the adapter's terminal render or wires that
153    // emit a closing frame (chat's [DONE], responses' response.completed)
154    // would close the stream twice.
155    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}