systemprompt_api/services/gateway/stream_tap/
mod.rs1mod accumulator;
5
6#[cfg(feature = "test-api")]
7pub mod test_api {
8 pub use super::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
9}
10
11use std::pin::Pin;
12use std::sync::{Arc, Mutex};
13use std::task::{Context, Poll};
14
15use axum::body::Body;
16use bytes::Bytes;
17use futures_util::stream::{BoxStream, Stream};
18
19use self::accumulator::{Summary, TapState, accumulate_event, extract_summary, snapshot};
20use super::audit::GatewayAudit;
21use super::protocol::canonical_response::CanonicalEvent;
22use super::protocol::inbound::InboundAdapter;
23
24pub fn tap(
25 upstream: BoxStream<'static, Result<CanonicalEvent, String>>,
26 inbound: Arc<dyn InboundAdapter>,
27 request_model: String,
28 audit: Arc<GatewayAudit>,
29) -> Body {
30 let state = Arc::new(Mutex::new(TapState::default()));
31 let tapped = TappedStream {
32 inner: upstream,
33 state: Arc::clone(&state),
34 inbound,
35 request_model,
36 audit,
37 };
38 Body::from_stream(tapped)
39}
40
41struct TappedStream {
42 inner: BoxStream<'static, Result<CanonicalEvent, String>>,
43 state: Arc<Mutex<TapState>>,
44 inbound: Arc<dyn InboundAdapter>,
45 request_model: String,
46 audit: Arc<GatewayAudit>,
47}
48
49impl Stream for TappedStream {
50 type Item = Result<Bytes, std::io::Error>;
51
52 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
53 loop {
54 match self.inner.as_mut().poll_next(cx) {
55 Poll::Pending => return Poll::Pending,
56 Poll::Ready(None) => {
57 return self.finalize_on_eof();
58 },
59 Poll::Ready(Some(Err(e))) => {
60 if let Ok(mut s) = self.state.lock() {
61 s.error = Some(e.clone());
62 }
63 let err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, e);
64 return Poll::Ready(Some(Err(err)));
65 },
66 Poll::Ready(Some(Ok(event))) => {
67 let terminal = matches!(
68 event,
69 CanonicalEvent::ContentBlockStop { .. }
70 | CanonicalEvent::MessageStop { .. }
71 );
72 let snap = self.state.lock().map_or(None, |mut s| {
73 accumulate_event(&mut s, &event);
74 terminal.then(|| snapshot(&s))
75 });
76 let rendered = snap
77 .as_ref()
78 .and_then(|snapshot| {
79 self.inbound.render_terminal_event(
80 &event,
81 snapshot,
82 &self.request_model,
83 )
84 })
85 .or_else(|| self.inbound.render_event(&event, &self.request_model));
86 if let Some(bytes) = rendered {
87 if let Ok(mut s) = self.state.lock() {
88 s.final_bytes.extend_from_slice(&bytes);
89 }
90 return Poll::Ready(Some(Ok(bytes)));
91 }
92 },
93 }
94 }
95 }
96}
97
98impl TappedStream {
99 fn take_summary(&self) -> Option<Summary> {
100 self.state.lock().ok().and_then(|mut s| {
101 if s.finalized {
102 return None;
103 }
104 s.finalized = true;
105 Some(extract_summary(&mut s))
106 })
107 }
108
109 fn finalize_on_eof(&self) -> Poll<Option<Result<Bytes, std::io::Error>>> {
110 let Some(summary) = self.take_summary() else {
111 return Poll::Ready(None);
112 };
113 finalize(Arc::clone(&self.audit), summary, "eof");
114 Poll::Ready(None)
115 }
116}
117
118impl Drop for TappedStream {
119 fn drop(&mut self) {
120 let Some(summary) = self.take_summary() else {
121 return;
122 };
123 finalize(Arc::clone(&self.audit), summary, "drop");
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum FinalizeDecision {
129 Fail(&'static str),
130 Complete { cost_capture_miss: bool },
131}
132
133pub const fn classify(
134 error: Option<&str>,
135 saw_stop: bool,
136 has_content: bool,
137 has_usage: bool,
138) -> FinalizeDecision {
139 if error.is_some() {
140 return FinalizeDecision::Fail("upstream stream error");
141 }
142 if !saw_stop {
143 return FinalizeDecision::Fail(if has_content {
144 "stream ended without stop event"
145 } else {
146 "empty upstream stream"
147 });
148 }
149 FinalizeDecision::Complete {
150 cost_capture_miss: has_content && !has_usage,
151 }
152}
153
154fn finalize(audit: Arc<GatewayAudit>, summary: Summary, origin: &'static str) {
155 tokio::spawn(async move {
156 if let Some(model) = summary.served_model.as_deref() {
157 audit.set_served_model(model).await;
158 }
159
160 let has_content = !summary.final_bytes.is_empty();
161 let has_usage = summary.usage.input_tokens > 0 || summary.usage.output_tokens > 0;
162 match classify(
163 summary.error.as_deref(),
164 summary.saw_stop,
165 has_content,
166 has_usage,
167 ) {
168 FinalizeDecision::Fail(reason) => {
169 let msg = summary.error.as_deref().unwrap_or(reason);
170 if let Err(e) = audit.fail(msg).await {
171 tracing::warn!(origin, error = %e, "stream audit fail failed");
172 }
173 },
174 FinalizeDecision::Complete { cost_capture_miss } => {
175 if cost_capture_miss {
176 tracing::warn!(
177 origin,
178 "stream completed with content but zero usage: cost capture miss"
179 );
180 }
181 if let Err(e) = audit
182 .complete(
183 summary.usage,
184 summary.tool_calls,
185 &summary.response,
186 &summary.final_bytes,
187 )
188 .await
189 {
190 tracing::warn!(origin, error = %e, "stream audit complete failed");
191 }
192 },
193 }
194 });
195}