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;
8mod finalize;
9
10#[cfg(feature = "test-api")]
11pub mod test_api {
12    pub use super::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
13}
14
15use std::pin::Pin;
16use std::sync::{Arc, Mutex};
17use std::task::{Context, Poll};
18
19use axum::body::Body;
20use bytes::Bytes;
21use futures_util::stream::{BoxStream, Stream};
22use systemprompt_database::DbPool;
23use systemprompt_identifiers::AiRequestId;
24
25use self::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
26use self::finalize::finalize;
27use super::audit::GatewayAudit;
28use super::policy::GatewayPolicySpec;
29use super::protocol::canonical_response::CanonicalEvent;
30use super::protocol::inbound::InboundAdapter;
31use super::protocol::outbound::anthropic::streaming::SseDecoder;
32
33pub use self::finalize::{FinalizeDecision, classify};
34
35/// Shared by the streaming and buffered completion tasks so both debit quota
36/// and run the response-phase safety scan identically.
37#[derive(Debug)]
38pub struct TapFinalizeCtx {
39    pub db: DbPool,
40    pub repos: crate::services::gateway::GatewayRepositories,
41    pub policy: GatewayPolicySpec,
42    pub ai_request_id: AiRequestId,
43}
44
45pub fn tap(
46    upstream: BoxStream<'static, Result<CanonicalEvent, String>>,
47    inbound: Arc<dyn InboundAdapter>,
48    request_model: String,
49    audit: Arc<GatewayAudit>,
50    finalize_ctx: TapFinalizeCtx,
51) -> Body {
52    let state = Arc::new(Mutex::new(TapState::default()));
53    let tapped = TappedStream {
54        inner: upstream,
55        state: Arc::clone(&state),
56        inbound,
57        request_model,
58        audit,
59        finalize_ctx: Some(finalize_ctx),
60        message_stop_rendered: false,
61    };
62    Body::from_stream(tapped)
63}
64
65pub fn tap_raw(
66    upstream: BoxStream<'static, Result<Bytes, String>>,
67    audit: Arc<GatewayAudit>,
68    finalize_ctx: TapFinalizeCtx,
69) -> Body {
70    Body::from_stream(RawTappedStream {
71        inner: upstream,
72        state: Arc::new(Mutex::new(TapState::default())),
73        decoder: SseDecoder::default(),
74        audit,
75        finalize_ctx: Some(finalize_ctx),
76    })
77}
78
79struct RawTappedStream {
80    inner: BoxStream<'static, Result<Bytes, String>>,
81    state: Arc<Mutex<TapState>>,
82    decoder: SseDecoder,
83    audit: Arc<GatewayAudit>,
84    finalize_ctx: Option<TapFinalizeCtx>,
85}
86
87impl RawTappedStream {
88    fn take_summary(&mut self) -> Option<(Summary, TapFinalizeCtx)> {
89        let ctx = self.finalize_ctx.take()?;
90        self.state.lock().ok().and_then(|mut s| {
91            if s.finalized {
92                return None;
93            }
94            s.finalized = true;
95            Some((extract_summary(&mut s), ctx))
96        })
97    }
98}
99
100impl Stream for RawTappedStream {
101    type Item = Result<Bytes, std::io::Error>;
102
103    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
104        match self.inner.as_mut().poll_next(cx) {
105            Poll::Pending => Poll::Pending,
106            Poll::Ready(None) => {
107                if let Some((summary, ctx)) = self.take_summary() {
108                    finalize(Arc::clone(&self.audit), summary, ctx, "eof");
109                }
110                Poll::Ready(None)
111            },
112            Poll::Ready(Some(Err(e))) => {
113                if let Ok(mut s) = self.state.lock() {
114                    s.error = Some(e.clone());
115                }
116                Poll::Ready(Some(Err(std::io::Error::new(
117                    std::io::ErrorKind::BrokenPipe,
118                    e,
119                ))))
120            },
121            Poll::Ready(Some(Ok(bytes))) => {
122                let events = self.decoder.push(&bytes);
123                if let Ok(mut s) = self.state.lock() {
124                    for event in &events {
125                        accumulate_event(&mut s, event);
126                    }
127                    s.final_bytes.extend_from_slice(&bytes);
128                }
129                Poll::Ready(Some(Ok(bytes)))
130            },
131        }
132    }
133}
134
135impl Drop for RawTappedStream {
136    fn drop(&mut self) {
137        let Some((summary, ctx)) = self.take_summary() else {
138            return;
139        };
140        finalize(Arc::clone(&self.audit), summary, ctx, "drop");
141    }
142}
143
144struct TappedStream {
145    inner: BoxStream<'static, Result<CanonicalEvent, String>>,
146    state: Arc<Mutex<TapState>>,
147    inbound: Arc<dyn InboundAdapter>,
148    request_model: String,
149    audit: Arc<GatewayAudit>,
150    finalize_ctx: Option<TapFinalizeCtx>,
151    // Why: providers signal the end of a message more than once (Anthropic's
152    // message_delta + message_stop, OpenAI's finish_reason chunk + [DONE]);
153    // only the first may drive the adapter's terminal render or wires that
154    // emit a closing frame (chat's [DONE], responses' response.completed)
155    // would close the stream twice.
156    message_stop_rendered: bool,
157}
158
159impl Stream for TappedStream {
160    type Item = Result<Bytes, std::io::Error>;
161
162    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
163        loop {
164            match self.inner.as_mut().poll_next(cx) {
165                Poll::Pending => return Poll::Pending,
166                Poll::Ready(None) => {
167                    return self.finalize_on_eof();
168                },
169                Poll::Ready(Some(Err(e))) => {
170                    if let Ok(mut s) = self.state.lock() {
171                        s.error = Some(e.clone());
172                    }
173                    let err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, e);
174                    return Poll::Ready(Some(Err(err)));
175                },
176                Poll::Ready(Some(Ok(event))) => {
177                    let is_message_stop = matches!(event, CanonicalEvent::MessageStop { .. });
178                    let terminal = matches!(event, CanonicalEvent::ContentBlockStop { .. })
179                        || (is_message_stop && !self.message_stop_rendered);
180                    let snap = self.state.lock().map_or(None, |mut s| {
181                        accumulate_event(&mut s, &event);
182                        terminal.then(|| snapshot(&s))
183                    });
184                    if is_message_stop {
185                        self.message_stop_rendered = true;
186                    }
187                    let rendered = snap
188                        .as_ref()
189                        .and_then(|snapshot| {
190                            self.inbound.render_terminal_event(
191                                &event,
192                                snapshot,
193                                &self.request_model,
194                            )
195                        })
196                        .or_else(|| self.inbound.render_event(&event, &self.request_model));
197                    if let Some(bytes) = rendered {
198                        if let Ok(mut s) = self.state.lock() {
199                            s.final_bytes.extend_from_slice(&bytes);
200                        }
201                        return Poll::Ready(Some(Ok(bytes)));
202                    }
203                },
204            }
205        }
206    }
207}
208
209impl TappedStream {
210    fn take_summary(&mut self) -> Option<(Summary, TapFinalizeCtx)> {
211        let ctx = self.finalize_ctx.take()?;
212        self.state.lock().ok().and_then(|mut s| {
213            if s.finalized {
214                return None;
215            }
216            s.finalized = true;
217            Some((extract_summary(&mut s), ctx))
218        })
219    }
220
221    fn finalize_on_eof(&mut self) -> Poll<Option<Result<Bytes, std::io::Error>>> {
222        let Some((summary, ctx)) = self.take_summary() else {
223            return Poll::Ready(None);
224        };
225        finalize(Arc::clone(&self.audit), summary, ctx, "eof");
226        Poll::Ready(None)
227    }
228}
229
230impl Drop for TappedStream {
231    fn drop(&mut self) {
232        let Some((summary, ctx)) = self.take_summary() else {
233            return;
234        };
235        finalize(Arc::clone(&self.audit), summary, ctx, "drop");
236    }
237}