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 abort;
8mod accumulator;
9mod finalize;
10
11#[cfg(feature = "test-api")]
12pub mod test_api {
13    pub use super::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
14}
15
16use std::pin::Pin;
17use std::sync::{Arc, Mutex};
18use std::task::{Context, Poll};
19
20use axum::body::Body;
21use bytes::Bytes;
22use futures_util::stream::{BoxStream, Stream};
23use systemprompt_database::DbPool;
24use systemprompt_identifiers::AiRequestId;
25
26use self::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
27use self::finalize::finalize;
28use super::audit::GatewayAudit;
29use super::policy::GatewayPolicySpec;
30use super::protocol::canonical_response::CanonicalEvent;
31use super::protocol::inbound::InboundAdapter;
32use super::protocol::outbound::anthropic::streaming::SseDecoder;
33
34pub use self::finalize::{FailCause, FinalizeDecision, classify};
35
36pub use self::abort::STREAM_ABORT_MESSAGE;
37
38/// Shared by the streaming and buffered completion tasks so both debit quota
39/// and run the response-phase safety scan identically.
40#[derive(Debug)]
41pub struct TapFinalizeCtx {
42    pub db: DbPool,
43    pub repos: crate::services::gateway::GatewayRepositories,
44    pub policy: GatewayPolicySpec,
45    pub ai_request_id: AiRequestId,
46}
47
48/// How the tapped stream is rendered back to the caller.
49///
50/// `stream_usage` is the caller's own `stream_options.include_usage`; it
51/// decides whether the closing frames carry a usage chunk.
52#[derive(Debug)]
53pub struct TapRender {
54    pub inbound: Arc<dyn InboundAdapter>,
55    pub request_model: String,
56    pub stream_usage: bool,
57}
58
59pub fn tap(
60    upstream: BoxStream<'static, Result<CanonicalEvent, String>>,
61    render: TapRender,
62    audit: Arc<GatewayAudit>,
63    finalize_ctx: TapFinalizeCtx,
64) -> Body {
65    let TapRender {
66        inbound,
67        request_model,
68        stream_usage,
69    } = render;
70    let state = Arc::new(Mutex::new(TapState::default()));
71    let tapped = TappedStream {
72        inner: upstream,
73        state: Arc::clone(&state),
74        inbound,
75        request_model,
76        stream_usage,
77        audit,
78        finalize_ctx: Some(finalize_ctx),
79        message_stop_rendered: false,
80        ended: false,
81    };
82    Body::from_stream(tapped)
83}
84
85// Why: on the byte-passthrough lane the caller receives the upstream frames
86// verbatim, so `inbound` is carried for one purpose only -- stating an abort.
87// The lane renders nothing of its own, so a stream that ends with no terminal
88// event would otherwise close on the client with no frame explaining it.
89pub fn tap_raw(
90    upstream: BoxStream<'static, Result<Bytes, String>>,
91    inbound: Arc<dyn InboundAdapter>,
92    audit: Arc<GatewayAudit>,
93    finalize_ctx: TapFinalizeCtx,
94) -> Body {
95    Body::from_stream(RawTappedStream {
96        inner: upstream,
97        state: Arc::new(Mutex::new(TapState::default())),
98        decoder: SseDecoder::default(),
99        inbound,
100        audit,
101        finalize_ctx: Some(finalize_ctx),
102        ended: false,
103    })
104}
105
106struct RawTappedStream {
107    inner: BoxStream<'static, Result<Bytes, String>>,
108    state: Arc<Mutex<TapState>>,
109    decoder: SseDecoder,
110    inbound: Arc<dyn InboundAdapter>,
111    audit: Arc<GatewayAudit>,
112    finalize_ctx: Option<TapFinalizeCtx>,
113    ended: bool,
114}
115
116impl RawTappedStream {
117    fn take_summary(&mut self) -> Option<(Summary, TapFinalizeCtx)> {
118        let ctx = self.finalize_ctx.take()?;
119        self.state.lock().ok().and_then(|mut s| {
120            if s.finalized {
121                return None;
122            }
123            s.finalized = true;
124            Some((extract_summary(&mut s), ctx))
125        })
126    }
127}
128
129impl Stream for RawTappedStream {
130    type Item = Result<Bytes, std::io::Error>;
131
132    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
133        if self.ended {
134            return Poll::Ready(None);
135        }
136        match self.inner.as_mut().poll_next(cx) {
137            Poll::Pending => Poll::Pending,
138            Poll::Ready(None) => {
139                self.ended = true;
140                let Some((summary, ctx)) = self.take_summary() else {
141                    return Poll::Ready(None);
142                };
143                let aborted = abort::is_abort(&summary);
144                finalize(Arc::clone(&self.audit), summary, ctx, "eof");
145                if !aborted {
146                    return Poll::Ready(None);
147                }
148                Poll::Ready(abort::abort_frame(&self.inbound, "").map(Ok))
149            },
150            Poll::Ready(Some(Err(e))) => {
151                if let Ok(mut s) = self.state.lock() {
152                    s.error = Some(e.clone());
153                }
154                Poll::Ready(Some(Err(std::io::Error::new(
155                    std::io::ErrorKind::BrokenPipe,
156                    e,
157                ))))
158            },
159            Poll::Ready(Some(Ok(bytes))) => {
160                let events = self.decoder.push(&bytes);
161                if let Ok(mut s) = self.state.lock() {
162                    for event in &events {
163                        accumulate_event(&mut s, event);
164                    }
165                    s.final_bytes.extend_from_slice(&bytes);
166                }
167                Poll::Ready(Some(Ok(bytes)))
168            },
169        }
170    }
171}
172
173impl Drop for RawTappedStream {
174    fn drop(&mut self) {
175        let Some((summary, ctx)) = self.take_summary() else {
176            return;
177        };
178        finalize(Arc::clone(&self.audit), summary, ctx, "drop");
179    }
180}
181
182struct TappedStream {
183    inner: BoxStream<'static, Result<CanonicalEvent, String>>,
184    state: Arc<Mutex<TapState>>,
185    inbound: Arc<dyn InboundAdapter>,
186    request_model: String,
187    // Why: the caller's own `stream_options.include_usage`; the trailing
188    // usage chunk is rendered only for a caller that asked for one.
189    stream_usage: bool,
190    audit: Arc<GatewayAudit>,
191    finalize_ctx: Option<TapFinalizeCtx>,
192    // Why: providers signal the end of a message more than once (Anthropic's
193    // message_delta + message_stop, OpenAI's finish_reason chunk + [DONE]);
194    // only the first may be rendered at all, by either the terminal path or
195    // the plain-event fallback, or wires that emit a closing frame (chat's
196    // [DONE], responses' response.completed, anthropic's message_stop) would
197    // close the stream twice -- the second one carrying the weaker reason.
198    message_stop_rendered: bool,
199    // Why: the abort frame is emitted after the inner stream has already
200    // reported EOF, so the next poll must not reach it again.
201    ended: bool,
202}
203
204impl Stream for TappedStream {
205    type Item = Result<Bytes, std::io::Error>;
206
207    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
208        if self.ended {
209            return Poll::Ready(None);
210        }
211        loop {
212            match self.inner.as_mut().poll_next(cx) {
213                Poll::Pending => return Poll::Pending,
214                Poll::Ready(None) => {
215                    self.ended = true;
216                    return self.finalize_on_eof();
217                },
218                Poll::Ready(Some(Err(e))) => {
219                    if let Ok(mut s) = self.state.lock() {
220                        s.error = Some(e.clone());
221                    }
222                    let err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, e);
223                    return Poll::Ready(Some(Err(err)));
224                },
225                Poll::Ready(Some(Ok(event))) => {
226                    let is_message_stop = matches!(event, CanonicalEvent::MessageStop { .. });
227                    let terminal = matches!(event, CanonicalEvent::ContentBlockStop { .. })
228                        || (is_message_stop && !self.message_stop_rendered);
229                    let snap = self.state.lock().map_or(None, |mut s| {
230                        accumulate_event(&mut s, &event);
231                        terminal.then(|| snapshot(&s))
232                    });
233                    let terminal_suppressed = is_message_stop && self.message_stop_rendered;
234                    if is_message_stop {
235                        self.message_stop_rendered = true;
236                    }
237                    let rendered = snap
238                        .as_ref()
239                        .and_then(|snapshot| {
240                            self.inbound.render_terminal_event(
241                                &event,
242                                snapshot,
243                                &self.request_model,
244                            )
245                        })
246                        .or_else(|| {
247                            // Why: `terminal` already suppressed the second
248                            // terminal render, but the plain-event fallback was
249                            // not covered -- the Anthropic inbound renders
250                            // MessageStop through `render_event`, so a repeat
251                            // stop still reached the client as a second,
252                            // weaker `message_stop` frame after the real one.
253                            (!terminal_suppressed)
254                                .then(|| self.inbound.render_event(&event, &self.request_model))
255                                .flatten()
256                        });
257                    if let Some(bytes) = rendered {
258                        if let Ok(mut s) = self.state.lock() {
259                            s.final_bytes.extend_from_slice(&bytes);
260                        }
261                        return Poll::Ready(Some(Ok(bytes)));
262                    }
263                },
264            }
265        }
266    }
267}
268
269impl TappedStream {
270    fn take_summary(&mut self) -> Option<(Summary, TapFinalizeCtx)> {
271        let ctx = self.finalize_ctx.take()?;
272        self.state.lock().ok().and_then(|mut s| {
273            if s.finalized {
274                return None;
275            }
276            s.finalized = true;
277            Some((extract_summary(&mut s), ctx))
278        })
279    }
280
281    fn finalize_on_eof(&mut self) -> Poll<Option<Result<Bytes, std::io::Error>>> {
282        let Some((summary, ctx)) = self.take_summary() else {
283            return Poll::Ready(None);
284        };
285        let aborted = abort::is_abort(&summary);
286        let tail = (!aborted)
287            .then(|| abort::tail_frames(&self.inbound, &summary.response, self.stream_usage))
288            .flatten();
289        finalize(Arc::clone(&self.audit), summary, ctx, "eof");
290        if !aborted {
291            return Poll::Ready(tail.map(Ok));
292        }
293        Poll::Ready(abort::abort_frame(&self.inbound, &self.request_model).map(Ok))
294    }
295}
296
297impl Drop for TappedStream {
298    fn drop(&mut self) {
299        let Some((summary, ctx)) = self.take_summary() else {
300            return;
301        };
302        finalize(Arc::clone(&self.audit), summary, ctx, "drop");
303    }
304}