allama_http/framing.rs
1//! Streaming for any wire framing: the chat stream driver and vendor
2//! frames.
3//!
4//! A streamed response arrives in *frames* of its wire framing: SSE events
5//! ([`sse`](crate::sse)), NDJSON lines ([`ndjson`](crate::ndjson)), or the
6//! messages of another framing an adapter frames itself. What turns frames
7//! into chat stream events is the same loop for all of them, and so are the
8//! rules a hand-written loop gets wrong, so they live here:
9//!
10//! - **The driver.** [`FramedChatStream`] runs an adapter's
11//! [`ChatFrameDecoder`] over a stream of frames: it ends the stream after
12//! the first error, runs the decoder's end-of-body or failure hook once,
13//! classifies a failed read (a stream timeout stays a timeout), and never
14//! polls the body after the stream ended.
15//! - **Vendor frames.** [`VendorFrames`] surfaces frames of a type an
16//! adapter does not model (`event: search_results`, an NDJSON line of an
17//! unknown `type`) as response-level provider data under the adapter's
18//! namespace, with one warning per frame type, so they are never decoded
19//! as the adapter's own and never dropped.
20//!
21//! Framing the bytes stays with a framer ([`SseStream`](crate::sse::SseStream),
22//! [`NdjsonStream`](crate::ndjson::NdjsonStream), or your own stream of
23//! frames), and decoding the frames' data with each adapter: the helpers
24//! frame, drive and surface, they do not interpret.
25
26use std::borrow::Cow;
27use std::collections::{BTreeMap, VecDeque};
28use std::fmt;
29use std::pin::Pin;
30use std::task::{Context, Poll, ready};
31
32use allama::{ProviderData, Result, StreamEvent, Subject, Warning, WarningKind};
33use futures_core::{Stream, TryStream};
34use serde_json::Value;
35
36/// An adapter's decoder for one streamed response: frames of type `F` in
37/// (an [`SseEvent`](crate::sse::SseEvent), an NDJSON line), chat stream
38/// events out. [`FramedChatStream`] drives it.
39///
40/// The decoder holds what only the adapter knows: its wire format, when its
41/// response starts and ends, and its error classification. The driver holds
42/// the loop every streaming adapter needs, and its guarantees:
43///
44/// - each frame goes to [`frame`](Self::frame), in order;
45/// - the stream ends after the first error, from the decoder or from the
46/// body: the items after it are dropped;
47/// - when the body ends, [`end`](Self::end) runs; when reading or framing
48/// it fails, [`fail`](Self::fail) runs, with the failure already
49/// classified. At most one of them runs, once, and only while the decoder
50/// is not done (it has yielded no error and [`is_done`](Self::is_done) is
51/// `false`);
52/// - the stream ends once the decoder is done, without waiting for the
53/// body to close, and the body is never polled after the stream ended.
54///
55/// A decoder yields warnings and response-level provider data as they
56/// arrive, even before its `ResponseStart`:
57/// [`Prepared::finish_stream`](allama::adapter::chat::Prepared::finish_stream) puts
58/// them after the start ([`start_first`](allama::adapter::chat::start_first)).
59///
60/// An SSE adapter implements `ChatFrameDecoder<SseEvent>`, and
61/// [`SseChatStream`](crate::sse::SseChatStream) drives it.
62///
63/// Each method returns the items of one frame as a `Vec`, and this shape is
64/// deliberate and stable. An empty `Vec` does not allocate, and a frame that
65/// yields events was decoded from JSON anyway, so an `out: &mut Vec<_>` sink
66/// parameter would save one small allocation per frame and cost every decoder
67/// a harder signature: early returns become pushes, and a decoder that
68/// forgets to push loses an error silently.
69///
70/// ```
71/// use allama::{Error, Result, StreamEvent, StreamErrorKind};
72/// use allama_http::TransportError;
73/// use allama_http::framing::{ChatFrameDecoder, FramedChatStream};
74/// use allama_http::ndjson::NdjsonStream;
75/// use futures_util::{FutureExt, StreamExt, stream};
76///
77/// /// `{"text": ..}` lines, then `{"done": true}`.
78/// struct Lines {
79/// started: bool,
80/// done: bool,
81/// }
82///
83/// impl ChatFrameDecoder<String> for Lines {
84/// fn frame(&mut self, line: String) -> Vec<Result<StreamEvent>> {
85/// let json: serde_json::Value = match serde_json::from_str(&line) {
86/// Ok(json) => json,
87/// Err(error) => return vec![Err(Error::stream_decode(error.to_string()))],
88/// };
89/// let mut out = Vec::new();
90/// if !self.started {
91/// self.started = true;
92/// out.push(Ok(StreamEvent::response_start(None, None)));
93/// out.push(Ok(StreamEvent::block_start(0, allama::StreamBlockType::Text)));
94/// }
95/// if json["done"] == true {
96/// self.done = true;
97/// out.push(Ok(StreamEvent::block_stop(0)));
98/// out.push(Ok(StreamEvent::ResponseStop));
99/// } else {
100/// out.push(Ok(StreamEvent::text_delta(0, json["text"].as_str().unwrap_or_default())));
101/// }
102/// out
103/// }
104///
105/// fn end(&mut self) -> Vec<Result<StreamEvent>> {
106/// let error = Error::stream(StreamErrorKind::Incomplete, "the stream ended early");
107/// vec![Err(error.authored().with_provider("lines"))]
108/// }
109///
110/// fn fail(&mut self, error: Error) -> Vec<Result<StreamEvent>> {
111/// vec![Err(error.with_provider("lines"))]
112/// }
113///
114/// fn is_done(&self) -> bool {
115/// self.done
116/// }
117/// }
118///
119/// let body = stream::iter([Ok::<_, TransportError>(
120/// &b"{\"text\":\"Hel\"}\n{\"text\":\"lo\"}\n{\"done\":true}\n"[..],
121/// )]);
122/// let lines = NdjsonStream::new(body);
123/// let stream = FramedChatStream::new(lines, Lines { started: false, done: false });
124/// let events: Vec<_> = stream.collect().now_or_never().unwrap();
125/// assert_eq!(events.len(), 6);
126/// assert!(matches!(events[5], Ok(StreamEvent::ResponseStop)));
127/// ```
128pub trait ChatFrameDecoder<F>: Send + 'static {
129 /// Decode one frame. An error among the items is the stream's last
130 /// item: the driver drops the items after it and ends the stream.
131 fn frame(&mut self, frame: F) -> Vec<Result<StreamEvent>>;
132
133 /// The body ended before the decoder was done: what the stream still
134 /// yields, typically a
135 /// [`StreamErrorKind::Incomplete`](allama::StreamErrorKind::Incomplete)
136 /// error, since the terminal frame never arrived. The stream ends after
137 /// it, whatever it returns, nothing included.
138 fn end(&mut self) -> Vec<Result<StreamEvent>>;
139
140 /// Reading or framing the body failed before the decoder was done.
141 /// `error` is the classified failure (the framer's error converted
142 /// into an [`allama::Error`], or the driver's error map). The default
143 /// yields it as it is; override it to close open blocks first, to
144 /// attach the provider and the request id, or to add what the adapter
145 /// knows about where to resume. The stream ends after it.
146 fn fail(&mut self, error: allama::Error) -> Vec<Result<StreamEvent>> {
147 vec![Err(error)]
148 }
149
150 /// Whether the decoder is done: its terminal frame arrived. The driver
151 /// then ends the stream without waiting for the body to close, and
152 /// calls neither [`end`](Self::end) nor [`fail`](Self::fail). The
153 /// default is `false`: the stream ends with the body.
154 fn is_done(&self) -> bool {
155 false
156 }
157}
158
159/// A chat stream decoded from a response's frames by an adapter's
160/// [`ChatFrameDecoder`], with the driver guarantees the decoder trait
161/// lists.
162///
163/// `frames` is any stream of `Result<frame, error>`: an
164/// [`SseStream`](crate::sse::SseStream), an
165/// [`NdjsonStream`](crate::ndjson::NdjsonStream), or a framer of your own.
166/// It is unboxed and `Send` (for a `Send` body); box it into a
167/// [`ChatStream`](allama::ChatStream) with
168/// [`Prepared::finish_stream`](allama::adapter::chat::Prepared::finish_stream):
169///
170/// ```ignore
171/// let lines = NdjsonStream::new(response.into_body()).max_line_size(max_stream_event_size);
172/// Ok(prepared.finish_stream(FramedChatStream::new(lines, StreamDecoder::new(request_id))))
173/// ```
174pub struct FramedChatStream<S: TryStream, D> {
175 /// The frames; `None` once the stream ended, so the body is dropped
176 /// and never polled again.
177 frames: Option<S>,
178 map: ErrorMap<S::Error>,
179 driver: Driver<D>,
180}
181
182impl<S: TryStream, D: ChatFrameDecoder<S::Ok>> FramedChatStream<S, D> {
183 /// Drive `decoder` over `frames`, classifying a failure with its
184 /// conversion into an [`allama::Error`]. The bundled framers' errors
185 /// convert for a body whose error converts into a
186 /// [`TransportError`](crate::TransportError), as an
187 /// [`HttpResponse`](crate::HttpResponse) body's does
188 /// ([`SseError::into_error`](crate::sse::SseError::into_error),
189 /// [`NdjsonError::into_error`](crate::ndjson::NdjsonError::into_error)).
190 #[must_use]
191 pub fn new(frames: S, decoder: D) -> Self
192 where
193 S::Error: Into<allama::Error> + 'static,
194 {
195 Self::with_error_map(frames, decoder, Into::into)
196 }
197
198 /// Drive `decoder` over `frames`, classifying a failure with `map`, for
199 /// a body whose error is not a [`TransportError`](crate::TransportError)
200 /// (a platform's own byte stream). `map` must keep the rules of the
201 /// framer's own classification: a timeout stays a timeout, bytes the
202 /// framer cannot decode are a stream decode error, a frame past the
203 /// size cap a stream protocol error, and the messages are the
204 /// adapter's own.
205 #[must_use]
206 pub fn with_error_map<M>(frames: S, decoder: D, map: M) -> Self
207 where
208 S::Error: 'static,
209 M: FnMut(S::Error) -> allama::Error + Send + 'static,
210 {
211 Self {
212 frames: Some(frames),
213 map: Box::new(map),
214 driver: Driver::new(decoder),
215 }
216 }
217}
218
219// The driver never pins its fields: it polls the frames through
220// `Pin::new`, which needs them `Unpin` itself.
221impl<S: TryStream, D> Unpin for FramedChatStream<S, D> {}
222
223impl<S, F, E, D> Stream for FramedChatStream<S, D>
224where
225 S: Stream<Item = std::result::Result<F, E>> + Unpin,
226 D: ChatFrameDecoder<F>,
227{
228 type Item = Result<StreamEvent>;
229
230 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
231 let this = self.get_mut();
232 this.driver.poll(&mut this.frames, &mut this.map, cx)
233 }
234}
235
236impl<S: TryStream, D> fmt::Debug for FramedChatStream<S, D> {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.debug_struct("FramedChatStream")
239 .field("ended", &self.frames.is_none())
240 .field("queued", &self.driver.queued())
241 .finish_non_exhaustive()
242 }
243}
244
245/// Surfaces vendor frames: stream frames of a type an adapter does not
246/// model.
247///
248/// Endpoints and proxies add frames to standard streams: a named SSE event
249/// (`event: search_results`), an NDJSON line of a `type` the API does not
250/// define. An adapter never decodes them as its own and never drops them:
251/// [`surface`](Self::surface) turns each one into events, as it arrives,
252/// and [`finish`](Self::finish) completes them when the response ends.
253///
254/// # Events
255///
256/// - The first frame of a type is preceded by one [`Warning`]
257/// (`WarningKind::Other`, `Subject::Response`, with the provider name).
258/// - Each frame yields a
259/// [`ResponseProviderData`](StreamEvent::ResponseProviderData) event
260/// under the adapter's namespace and the frame type, holding that frame's
261/// payload alone, as a one-element array: a live consumer sees each
262/// payload once, as it arrives, in constant work per frame.
263/// - [`finish`](Self::finish), called by the decoder just before its
264/// `ResponseStop`, yields one more `ResponseProviderData` event holding
265/// the whole array, in arrival order, of every type that arrived more
266/// than once. [`ProviderData::merge`] replaces a value by key, so a
267/// collector that merges the events ends with every payload of every
268/// type: the collected response's provider data holds the full arrays.
269///
270/// Before 2026-10-07 each event carried the whole array so far, which made a
271/// stream with a vendor frame per chunk quadratic in time and in what a
272/// recording holds. An adapter that forgets [`finish`](Self::finish) keeps
273/// only the last payload of a repeated type in its collected response.
274///
275/// A vendor frame never starts the response. Yield the returned events as
276/// they come, also before `ResponseStart`:
277/// [`Prepared::finish_stream`](allama::adapter::chat::Prepared::finish_stream) holds
278/// them until the start ([`start_first`](allama::adapter::chat::start_first)).
279/// `allama_conformance::stream_frames::assert_unknown_stream_frames_surfaced`
280/// checks the result.
281///
282/// ```
283/// use allama::{StreamEvent, WarningKind};
284/// use allama_http::framing::VendorFrames;
285///
286/// let mut frames = VendorFrames::new("demo", "openai-compat:demo");
287/// let first = frames.surface("search_results", r#"{"url":"https://a"}"#);
288/// assert!(matches!(
289/// &first[0],
290/// StreamEvent::Warning { warning, .. } if warning.kind == WarningKind::Other
291/// ));
292/// let second = frames.surface("search_results", "not json");
293/// let StreamEvent::ResponseProviderData { provider_data, .. } = &second[0] else {
294/// panic!("{second:?}")
295/// };
296/// assert_eq!(
297/// provider_data.get_raw("openai-compat:demo", "search_results"),
298/// Some(&serde_json::json!(["not json"])),
299/// );
300///
301/// // Before `ResponseStop`: the whole array of each repeated type.
302/// let Some(StreamEvent::ResponseProviderData { provider_data, .. }) = frames.finish() else {
303/// panic!()
304/// };
305/// assert_eq!(
306/// provider_data.get_raw("openai-compat:demo", "search_results"),
307/// Some(&serde_json::json!([{"url": "https://a"}, "not json"])),
308/// );
309/// ```
310#[derive(Debug, Clone)]
311pub struct VendorFrames {
312 provider: Cow<'static, str>,
313 namespace: Cow<'static, str>,
314 /// Payloads so far, by frame type.
315 payloads: BTreeMap<String, Vec<Value>>,
316}
317
318impl VendorFrames {
319 /// Vendor frames of a `provider` (for the warnings), surfaced under its
320 /// `namespace`.
321 #[must_use]
322 pub fn new(
323 provider: impl Into<Cow<'static, str>>,
324 namespace: impl Into<Cow<'static, str>>,
325 ) -> Self {
326 Self {
327 provider: provider.into(),
328 namespace: namespace.into(),
329 payloads: BTreeMap::new(),
330 }
331 }
332
333 /// Surface the raw `data` of a frame of type `event`: kept as JSON when
334 /// it parses, as the trimmed text otherwise. Empty data surfaces
335 /// nothing.
336 #[must_use]
337 pub fn surface(&mut self, event: &str, data: &str) -> Vec<StreamEvent> {
338 let data = data.trim();
339 if data.is_empty() {
340 return Vec::new();
341 }
342 let payload = serde_json::from_str(data).unwrap_or_else(|_| Value::String(data.to_owned()));
343 self.surface_payload(event, payload)
344 }
345
346 /// Surface a payload already decoded, for a frame whose type arrives
347 /// inside its data (an NDJSON line's `type`, an SSE event's JSON
348 /// `type`) rather than as an SSE event name.
349 #[must_use]
350 pub fn surface_payload(&mut self, event: &str, payload: Value) -> Vec<StreamEvent> {
351 let payloads = self.payloads.entry(event.to_owned()).or_default();
352 let mut events = Vec::with_capacity(2);
353 if payloads.is_empty() {
354 events.push(StreamEvent::warning(
355 Warning::new(
356 WarningKind::Other,
357 Subject::Response,
358 format!(
359 "unrecognized stream event `{event}`; its data is kept in the \
360 response's provider data under `{}`",
361 self.namespace
362 ),
363 )
364 .with_provider(self.provider.clone()),
365 ));
366 }
367 events.push(StreamEvent::response_provider_data(
368 ProviderData::new().with_raw(
369 self.namespace.clone(),
370 event,
371 Value::Array(vec![payload.clone()]),
372 ),
373 ));
374 payloads.push(payload);
375 events
376 }
377
378 /// The event that completes the surfaced frames, for the decoder to
379 /// yield just before its `ResponseStop`: one
380 /// [`ResponseProviderData`](StreamEvent::ResponseProviderData) holding
381 /// the whole array of every type that arrived more than once, which
382 /// replaces the single payloads a collector merged. `None` when no type
383 /// repeated (the events already carry every payload). The frames are
384 /// taken: a second call yields nothing.
385 #[must_use]
386 pub fn finish(&mut self) -> Option<StreamEvent> {
387 let mut provider_data = ProviderData::new();
388 let mut any = false;
389 for (event, payloads) in std::mem::take(&mut self.payloads) {
390 if payloads.len() > 1 {
391 provider_data.insert_raw(self.namespace.clone(), event, Value::Array(payloads));
392 any = true;
393 }
394 }
395 any.then(|| StreamEvent::response_provider_data(provider_data))
396 }
397}
398
399/// Classifies a framer's failure.
400type ErrorMap<E> = Box<dyn FnMut(E) -> allama::Error + Send>;
401
402/// The loop of [`FramedChatStream`]: a decoder and the items it yielded
403/// that the stream has not.
404pub(crate) struct Driver<D> {
405 decoder: D,
406 queue: VecDeque<Result<StreamEvent>>,
407}
408
409impl<D> Driver<D> {
410 pub(crate) fn new(decoder: D) -> Self {
411 Self {
412 decoder,
413 queue: VecDeque::new(),
414 }
415 }
416
417 pub(crate) fn queued(&self) -> usize {
418 self.queue.len()
419 }
420
421 /// The next item of the stream decoded from `frames`, which is set to
422 /// `None` once the stream ended.
423 pub(crate) fn poll<S, F, E>(
424 &mut self,
425 frames: &mut Option<S>,
426 map: &mut ErrorMap<E>,
427 cx: &mut Context<'_>,
428 ) -> Poll<Option<Result<StreamEvent>>>
429 where
430 S: Stream<Item = std::result::Result<F, E>> + Unpin,
431 D: ChatFrameDecoder<F>,
432 {
433 loop {
434 if let Some(item) = self.queue.pop_front() {
435 return Poll::Ready(Some(item));
436 }
437 let Some(stream) = frames.as_mut() else {
438 return Poll::Ready(None);
439 };
440 let (items, last) = match ready!(Pin::new(stream).poll_next(cx)) {
441 Some(Ok(frame)) => (self.decoder.frame(frame), false),
442 Some(Err(error)) => (self.decoder.fail(map(error)), true),
443 None => (self.decoder.end(), true),
444 };
445 if self.queue(items) || last || self.decoder.is_done() {
446 *frames = None;
447 }
448 }
449 }
450
451 /// Queue `items` up to and including the first error, and say whether
452 /// there was one.
453 fn queue(&mut self, items: Vec<Result<StreamEvent>>) -> bool {
454 for item in items {
455 let failed = item.is_err();
456 self.queue.push_back(item);
457 if failed {
458 return true;
459 }
460 }
461 false
462 }
463}
464
465#[cfg(all(test, not(target_arch = "wasm32")))]
466mod tests;