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 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
62/// Relays an upstream byte stream unchanged while accounting for what passes.
63///
64/// The client receives the provider's own SSE frames; a decoded copy feeds the
65/// same accumulator the translating tap uses, so audit, cost, quota, and the
66/// response-phase safety scan behave identically on both lanes.
67pub 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}