Skip to main content

gglib_core/normalize/
stream.rs

1//! [`NormalizingStream`] — the single wrap point that canonicalises an
2//! LLM event stream.
3//!
4//! Adapters that implement [`crate::ports::LlmCompletionPort`] wrap the
5//! inner SSE-derived stream **once** with `NormalizingStream::new(inner,
6//! get_parser(&model.tags))`.  Every downstream consumer (Axum SSE, CLI,
7//! Tauri, the proxy, the agent loop) then sees a strict OpenAI-shaped
8//! sequence of [`LlmStreamEvent`] values, regardless of which dialect the
9//! underlying model speaks.
10//!
11//! ## Translation rules
12//!
13//! - `TextDelta` → routed through [`ToolCallParser::push_text`]; the parser
14//!   may strip dialect markup and synthesise [`LlmStreamEvent::ToolCallDelta`]
15//!   events for any extracted tool calls.
16//! - `ReasoningDelta` → routed through [`ToolCallParser::push_reasoning`]
17//!   symmetrically.
18//! - `ToolCallDelta` → forwarded unchanged (already conformant).  The
19//!   wrapper records the highest seen `index` so synthesised deltas use
20//!   non-colliding indices.
21//! - `PromptProgress` → forwarded unchanged.
22//! - `Done` → [`ToolCallParser::finish`] is called first, any flushed
23//!   bytes / tool calls / errors are emitted, **then** `Done` is forwarded.
24//!   The contract that every stream carries exactly one `Done` item is
25//!   preserved — but `Done` is *not* treated as an absolute end-of-stream
26//!   signal. Per the `OpenAI` `stream_options.include_usage` convention (and
27//!   llama.cpp's actual wire behaviour), a trailing [`LlmStreamEvent::Usage`]
28//!   event can legitimately arrive *after* `Done`, before the underlying
29//!   byte stream closes — that event (and `PromptProgress` /
30//!   `NormalizationError` / `UpstreamError`) still gets forwarded. Any
31//!   content-bearing event arriving after `Done` (`TextDelta`,
32//!   `ReasoningDelta`, `ToolCallDelta`, a second `Done`) is dropped
33//!   defensively, since a well-formed stream never sends those.
34//!
35//! ## Errors
36//!
37//! Upstream `Err` items terminate the stream early (we propagate them
38//! verbatim).  Non-fatal normalization issues from the parser are surfaced
39//! as [`LlmStreamEvent::NormalizationError`] events; they do **not**
40//! terminate the stream.
41
42use std::collections::VecDeque;
43use std::pin::Pin;
44use std::task::{Context, Poll};
45
46use anyhow::Result;
47use futures_core::Stream;
48
49use super::parser::{ParserOutput, ToolCallParser};
50use crate::domain::agent::{LlmStreamEvent, ToolCall};
51
52type InnerStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
53
54/// Stream adapter that runs every event through a [`ToolCallParser`] before
55/// re-emitting the normalized result.  See module docs.
56pub struct NormalizingStream {
57    inner: InnerStream,
58    parser: Box<dyn ToolCallParser>,
59    /// Events ready to emit on the next poll.  A single upstream event can
60    /// expand to many downstream events (e.g. `Done` flushes parser state
61    /// before propagating).
62    queued: VecDeque<LlmStreamEvent>,
63    /// Lowest tool-call index that is safe to use for a synthesised delta.
64    /// Bumped past every upstream `index` we observe so downstream
65    /// collectors can use indices as keys without collision.
66    next_index: usize,
67    /// `true` once the upstream `inner` stream is fully exhausted (ended or
68    /// errored).  Subsequent polls return `None`.  Note this is **not** set
69    /// merely because a `Done` event was seen — see `done_forwarded`.
70    terminated: bool,
71    /// `true` once we've forwarded the upstream `Done` event.
72    ///
73    /// `Done` is *not* treated as an absolute end-of-stream signal: per the
74    /// `OpenAI` `stream_options.include_usage` convention (and llama.cpp's
75    /// wire behaviour), a trailing `Usage` event legitimately arrives
76    /// *after* the `finish_reason`/`Done` chunk, before the underlying byte
77    /// stream actually closes. Once this flag is set, only trailer-safe
78    /// events (`Usage`, `PromptProgress`, `NormalizationError`,
79    /// `UpstreamError`) are still queued; any further content-bearing event
80    /// (`TextDelta`, `ReasoningDelta`, `ToolCallDelta`, a second `Done`) is
81    /// dropped defensively — a well-formed stream never sends these after
82    /// `Done`, and the parser has already been finalised.
83    done_forwarded: bool,
84}
85
86impl NormalizingStream {
87    /// Wrap `inner` so every event is normalized through `parser`.
88    #[must_use]
89    pub fn new(inner: InnerStream, parser: Box<dyn ToolCallParser>) -> Self {
90        Self {
91            inner,
92            parser,
93            queued: VecDeque::new(),
94            next_index: 0,
95            terminated: false,
96            done_forwarded: false,
97        }
98    }
99
100    /// Translate one parser output batch into the queued event sequence.
101    fn enqueue_parser_output(&mut self, mut out: ParserOutput) {
102        if !out.forward_text.is_empty() {
103            // Strip stray `<think>` / `</think>` boundary tags from text
104            // content.  Reasoning models (e.g. Qwen3) send their chain-of-
105            // thought in `reasoning_content` SSE fields but leak the closing
106            // `</think>` marker into the regular `content` field when
107            // transitioning back to output mode.  These tags carry no
108            // semantic meaning for the client and produce visible artefacts
109            // (e.g. `</think>` appearing verbatim in Zed's chat pane).
110            let text = std::mem::take(&mut out.forward_text);
111            let text = text.replace("</think>", "").replace("<think>", "");
112            if !text.is_empty() {
113                self.queued
114                    .push_back(LlmStreamEvent::TextDelta { content: text });
115            }
116        }
117        if !out.forward_reasoning.is_empty() {
118            self.queued.push_back(LlmStreamEvent::ReasoningDelta {
119                content: std::mem::take(&mut out.forward_reasoning),
120            });
121        }
122        for ToolCall {
123            id,
124            name,
125            arguments,
126        } in out.tool_calls
127        {
128            let index = self.next_index;
129            self.next_index += 1;
130            self.queued.push_back(LlmStreamEvent::ToolCallDelta {
131                index,
132                id: Some(id),
133                name: Some(name),
134                arguments: Some(arguments.to_string()),
135            });
136        }
137        for err in out.errors {
138            self.queued.push_back(LlmStreamEvent::NormalizationError {
139                kind: err.kind,
140                raw: err.raw,
141            });
142        }
143    }
144
145    /// Process one upstream event and queue the resulting downstream events.
146    fn handle_upstream(&mut self, event: LlmStreamEvent) {
147        match event {
148            LlmStreamEvent::TextDelta { content } => {
149                if self.done_forwarded {
150                    tracing::warn!("NormalizingStream: dropping TextDelta received after Done");
151                    return;
152                }
153                let out = self.parser.push_text(&content);
154                self.enqueue_parser_output(out);
155            }
156            LlmStreamEvent::ReasoningDelta { content } => {
157                if self.done_forwarded {
158                    tracing::warn!(
159                        "NormalizingStream: dropping ReasoningDelta received after Done"
160                    );
161                    return;
162                }
163                let out = self.parser.push_reasoning(&content);
164                self.enqueue_parser_output(out);
165            }
166            LlmStreamEvent::ToolCallDelta {
167                index,
168                id,
169                name,
170                arguments,
171            } => {
172                if self.done_forwarded {
173                    tracing::warn!("NormalizingStream: dropping ToolCallDelta received after Done");
174                    return;
175                }
176                if index >= self.next_index {
177                    self.next_index = index + 1;
178                }
179                self.queued.push_back(LlmStreamEvent::ToolCallDelta {
180                    index,
181                    id,
182                    name,
183                    arguments,
184                });
185            }
186            LlmStreamEvent::PromptProgress { .. }
187            | LlmStreamEvent::NormalizationError { .. }
188            | LlmStreamEvent::Usage { .. }
189            | LlmStreamEvent::UpstreamError { .. } => {
190                // Trailer-safe: legitimately arrives before *or* after Done
191                // (e.g. a trailing Usage chunk), so no done_forwarded guard.
192                self.queued.push_back(event);
193            }
194            LlmStreamEvent::Done { finish_reason } => {
195                if self.done_forwarded {
196                    tracing::warn!("NormalizingStream: dropping duplicate Done event");
197                    return;
198                }
199                let out = self.parser.finish();
200                self.enqueue_parser_output(out);
201                // Qwen3.5 (and some other models) emit tool_calls in the
202                // stream but finish with `finish_reason: "stop"` instead of
203                // the required `"tool_calls"`.  Clients such as Zed check
204                // finish_reason to decide whether to dispatch tool results;
205                // a wrong value causes the conversation to hang.
206                let finish_reason = if finish_reason == "stop" && self.next_index > 0 {
207                    "tool_calls".to_owned()
208                } else {
209                    finish_reason
210                };
211                self.queued
212                    .push_back(LlmStreamEvent::Done { finish_reason });
213                // Deliberately *not* `self.terminated = true` here — see the
214                // `done_forwarded` doc comment. The stream keeps polling
215                // `inner` (in `poll_next`) so a legitimate trailing `Usage`
216                // event can still be forwarded before the byte stream
217                // actually closes.
218                self.done_forwarded = true;
219            }
220        }
221    }
222}
223
224impl Stream for NormalizingStream {
225    type Item = Result<LlmStreamEvent>;
226
227    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
228        loop {
229            if let Some(ev) = self.queued.pop_front() {
230                return Poll::Ready(Some(Ok(ev)));
231            }
232            if self.terminated {
233                return Poll::Ready(None);
234            }
235            match self.inner.as_mut().poll_next(cx) {
236                Poll::Pending => return Poll::Pending,
237                Poll::Ready(Some(Ok(event))) => {
238                    self.handle_upstream(event);
239                    // Loop to drain `queued` (or poll inner again if empty).
240                }
241                Poll::Ready(Some(Err(e))) => {
242                    self.terminated = true;
243                    return Poll::Ready(Some(Err(e)));
244                }
245                Poll::Ready(None) => {
246                    // Upstream ended without a `Done`.  Flush any held-back
247                    // parser state so no bytes are lost, then end.  Skipped
248                    // when `Done` was already forwarded (e.g. only a
249                    // trailing `Usage` event followed it) — the parser was
250                    // already finalised in `handle_upstream`'s `Done` arm,
251                    // and finishing it twice would re-flush stale state.
252                    if !self.done_forwarded {
253                        let out = self.parser.finish();
254                        self.enqueue_parser_output(out);
255                    }
256                    self.terminated = true;
257                    if let Some(ev) = self.queued.pop_front() {
258                        return Poll::Ready(Some(Ok(ev)));
259                    }
260                    return Poll::Ready(None);
261                }
262            }
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::normalize::{registry::get_parser, tags};
271    use std::task::Poll;
272
273    /// Minimal hand-rolled stream that yields a fixed sequence of events.
274    struct VecStream {
275        items: VecDeque<Result<LlmStreamEvent>>,
276    }
277
278    impl VecStream {
279        fn new(items: Vec<Result<LlmStreamEvent>>) -> Self {
280            Self {
281                items: items.into(),
282            }
283        }
284    }
285
286    impl Stream for VecStream {
287        type Item = Result<LlmStreamEvent>;
288        fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
289            Poll::Ready(self.items.pop_front())
290        }
291    }
292
293    fn drain(mut s: NormalizingStream) -> Vec<LlmStreamEvent> {
294        // Poll synchronously with std's no-op waker.  Our test stream is
295        // always Ready, so we never observe Pending.
296        let waker = std::task::Waker::noop();
297        let mut cx = Context::from_waker(waker);
298        let mut out = Vec::new();
299        loop {
300            match Pin::new(&mut s).poll_next(&mut cx) {
301                Poll::Ready(Some(Ok(ev))) => out.push(ev),
302                Poll::Ready(Some(Err(e))) => panic!("unexpected error: {e}"),
303                Poll::Ready(None) => return out,
304                Poll::Pending => panic!("test stream returned Pending"),
305            }
306        }
307    }
308
309    fn wrap(events: Vec<LlmStreamEvent>, qwen: bool) -> NormalizingStream {
310        let inner: InnerStream = Box::pin(VecStream::new(events.into_iter().map(Ok).collect()));
311        let parser = if qwen {
312            get_parser(&[tags::FORMAT_QWEN_XML.to_owned()])
313        } else {
314            get_parser(&[])
315        };
316        NormalizingStream::new(inner, parser)
317    }
318
319    #[test]
320    fn standard_parser_is_passthrough() {
321        let events = vec![
322            LlmStreamEvent::TextDelta {
323                content: "hello".into(),
324            },
325            LlmStreamEvent::Done {
326                finish_reason: "stop".into(),
327            },
328        ];
329        let out = drain(wrap(events.clone(), false));
330        assert_eq!(out, events);
331    }
332
333    #[test]
334    fn usage_event_passes_through_unchanged() {
335        let events = vec![
336            LlmStreamEvent::TextDelta {
337                content: "hello".into(),
338            },
339            LlmStreamEvent::Usage {
340                prompt_tokens: 10,
341                completion_tokens: 5,
342                total_tokens: 15,
343                cached_tokens: None,
344            },
345            LlmStreamEvent::Done {
346                finish_reason: "stop".into(),
347            },
348        ];
349        let out = drain(wrap(events.clone(), false));
350        assert_eq!(out, events);
351    }
352
353    #[test]
354    fn usage_event_after_done_still_forwarded() {
355        // The actual llama.cpp/OpenAI wire order: the finish_reason chunk
356        // (-> Done) arrives *before* the trailing usage-only chunk
357        // (-> Usage). NormalizingStream must not treat Done as a hard
358        // stream-end that discards this legitimate trailer.
359        let events = vec![
360            LlmStreamEvent::TextDelta {
361                content: "hello".into(),
362            },
363            LlmStreamEvent::Done {
364                finish_reason: "stop".into(),
365            },
366            LlmStreamEvent::Usage {
367                prompt_tokens: 10,
368                completion_tokens: 5,
369                total_tokens: 15,
370                cached_tokens: None,
371            },
372        ];
373        let out = drain(wrap(events.clone(), false));
374        assert_eq!(
375            out, events,
376            "Usage arriving after Done must still be forwarded, in order"
377        );
378    }
379
380    #[test]
381    fn text_delta_after_done_is_dropped_defensively() {
382        // Malformed upstream: content arriving after Done. Must not panic
383        // or resurrect already-finalised parser state; simply dropped.
384        let events = vec![
385            LlmStreamEvent::Done {
386                finish_reason: "stop".into(),
387            },
388            LlmStreamEvent::TextDelta {
389                content: "should be dropped".into(),
390            },
391            LlmStreamEvent::Usage {
392                prompt_tokens: 1,
393                completion_tokens: 1,
394                total_tokens: 2,
395                cached_tokens: None,
396            },
397        ];
398        let out = drain(wrap(events, false));
399        assert_eq!(
400            out,
401            vec![
402                LlmStreamEvent::Done {
403                    finish_reason: "stop".into(),
404                },
405                LlmStreamEvent::Usage {
406                    prompt_tokens: 1,
407                    completion_tokens: 1,
408                    total_tokens: 2,
409                    cached_tokens: None,
410                },
411            ],
412            "stray TextDelta after Done must be dropped, Usage still forwarded"
413        );
414    }
415
416    #[test]
417    fn qwen_xml_in_text_is_extracted_to_tool_call_delta() {
418        let events = vec![
419            LlmStreamEvent::TextDelta {
420                content: r#"hi <tool_call>{"name":"foo","arguments":{"x":1}}</tool_call> done"#
421                    .into(),
422            },
423            LlmStreamEvent::Done {
424                finish_reason: "tool_calls".into(),
425            },
426        ];
427        let out = drain(wrap(events, true));
428        // Expect: TextDelta("hi  done"), ToolCallDelta, Done.
429        assert_eq!(out.len(), 3);
430        assert!(matches!(
431            &out[0],
432            LlmStreamEvent::TextDelta { content } if content == "hi  done"
433        ));
434        match &out[1] {
435            LlmStreamEvent::ToolCallDelta {
436                index,
437                id,
438                name,
439                arguments,
440            } => {
441                assert_eq!(*index, 0);
442                assert_eq!(id.as_deref(), Some("call_qwen_0"));
443                assert_eq!(name.as_deref(), Some("foo"));
444                assert_eq!(arguments.as_deref(), Some(r#"{"x":1}"#));
445            }
446            other => panic!("expected ToolCallDelta, got {other:?}"),
447        }
448        assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
449    }
450
451    #[test]
452    fn qwen_xml_in_reasoning_is_extracted_and_text_clean() {
453        let events = vec![
454            LlmStreamEvent::ReasoningDelta {
455                content: r#"think <tool_call>{"name":"foo","arguments":{}}</tool_call> end"#.into(),
456            },
457            LlmStreamEvent::Done {
458                finish_reason: "tool_calls".into(),
459            },
460        ];
461        let out = drain(wrap(events, true));
462        assert_eq!(out.len(), 3);
463        assert!(matches!(
464            &out[0],
465            LlmStreamEvent::ReasoningDelta { content } if content == "think  end"
466        ));
467        assert!(matches!(out[1], LlmStreamEvent::ToolCallDelta { .. }));
468        assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
469    }
470
471    #[test]
472    fn synthesised_index_does_not_collide_with_upstream() {
473        let events = vec![
474            LlmStreamEvent::ToolCallDelta {
475                index: 0,
476                id: Some("native".into()),
477                name: Some("nat".into()),
478                arguments: Some("{}".into()),
479            },
480            LlmStreamEvent::TextDelta {
481                content: r#"<tool_call>{"name":"foo","arguments":{}}</tool_call>"#.into(),
482            },
483            LlmStreamEvent::Done {
484                finish_reason: "stop".into(),
485            },
486        ];
487        let out = drain(wrap(events, true));
488        // Native delta + synthesised delta + Done = 3.
489        assert_eq!(out.len(), 3);
490        let LlmStreamEvent::ToolCallDelta { index: idx0, .. } = &out[0] else {
491            panic!()
492        };
493        let LlmStreamEvent::ToolCallDelta { index: idx1, .. } = &out[1] else {
494            panic!()
495        };
496        assert_eq!(*idx0, 0);
497        assert_eq!(*idx1, 1);
498    }
499
500    #[test]
501    fn unclosed_tag_at_done_emits_normalization_error_then_done() {
502        let events = vec![
503            LlmStreamEvent::TextDelta {
504                content: r#"<tool_call>{"name":"foo""#.into(),
505            },
506            LlmStreamEvent::Done {
507                finish_reason: "stop".into(),
508            },
509        ];
510        let out = drain(wrap(events, true));
511        // Expect at least: NormalizationError, Done.
512        assert!(matches!(out.last(), Some(LlmStreamEvent::Done { .. })));
513        assert!(
514            out.iter()
515                .any(|e| matches!(e, LlmStreamEvent::NormalizationError { .. }))
516        );
517    }
518
519    #[test]
520    fn upstream_ends_without_done_flushes_parser() {
521        // No Done at all — wrapper should still terminate cleanly and
522        // surface any held-back text.
523        let events = vec![LlmStreamEvent::TextDelta {
524            content: "<tool".into(),
525        }];
526        let out = drain(wrap(events, true));
527        assert_eq!(out.len(), 1);
528        assert!(matches!(
529            &out[0],
530            LlmStreamEvent::TextDelta { content } if content == "<tool"
531        ));
532    }
533
534    /// Qwen3.5 emits `tool_calls` in the stream but finishes with
535    /// `finish_reason: "stop"` instead of `"tool_calls"`.  The normalizer
536    /// must correct this so clients that gate tool dispatch on `finish_reason`
537    /// (e.g. Zed) do not hang.
538    #[test]
539    fn finish_reason_corrected_to_tool_calls_when_tool_calls_seen() {
540        let events = vec![
541            LlmStreamEvent::ToolCallDelta {
542                index: 0,
543                id: Some("call_0".into()),
544                name: Some("read_file".into()),
545                arguments: Some(r#"{"path":"/tmp/x"}"#.into()),
546            },
547            LlmStreamEvent::Done {
548                finish_reason: "stop".into(), // wrong — model bug
549            },
550        ];
551        let out = drain(wrap(events, false));
552        assert_eq!(out.len(), 2);
553        match &out[1] {
554            LlmStreamEvent::Done { finish_reason } => {
555                assert_eq!(finish_reason, "tool_calls");
556            }
557            other => panic!("expected Done, got {other:?}"),
558        }
559    }
560
561    /// When no tool calls were emitted, `finish_reason: "stop"` must be
562    /// left unchanged.
563    #[test]
564    fn finish_reason_stop_unchanged_when_no_tool_calls() {
565        let events = vec![
566            LlmStreamEvent::TextDelta {
567                content: "hello".into(),
568            },
569            LlmStreamEvent::Done {
570                finish_reason: "stop".into(),
571            },
572        ];
573        let out = drain(wrap(events, false));
574        match &out[1] {
575            LlmStreamEvent::Done { finish_reason } => {
576                assert_eq!(finish_reason, "stop");
577            }
578            other => panic!("expected Done, got {other:?}"),
579        }
580    }
581
582    /// Stray `</think>` closing tags emitted in text content by reasoning
583    /// models (e.g. Qwen3) must be stripped before reaching the client.
584    #[test]
585    fn stray_close_think_tag_stripped_from_text() {
586        let events = vec![
587            LlmStreamEvent::TextDelta {
588                content: "</think>\n\n".into(),
589            },
590            LlmStreamEvent::TextDelta {
591                content: "actual answer".into(),
592            },
593            LlmStreamEvent::Done {
594                finish_reason: "stop".into(),
595            },
596        ];
597        let out = drain(wrap(events, false));
598        // First delta should be dropped entirely (only whitespace after stripping).
599        // Second delta passes through unchanged.
600        let texts: Vec<_> = out
601            .iter()
602            .filter_map(|e| {
603                if let LlmStreamEvent::TextDelta { content } = e {
604                    Some(content.as_str())
605                } else {
606                    None
607                }
608            })
609            .collect();
610        assert!(
611            !texts.iter().any(|t| t.contains("</think>")),
612            "found </think> in output: {texts:?}"
613        );
614        assert!(texts.iter().any(|t| t.contains("actual answer")));
615    }
616
617    /// `<think>` open tags should also be stripped from text content.
618    #[test]
619    fn stray_open_think_tag_stripped_from_text() {
620        let events = vec![
621            LlmStreamEvent::TextDelta {
622                content: "<think>spurious</think>real text".into(),
623            },
624            LlmStreamEvent::Done {
625                finish_reason: "stop".into(),
626            },
627        ];
628        let out = drain(wrap(events, false));
629        let texts: Vec<_> = out
630            .iter()
631            .filter_map(|e| {
632                if let LlmStreamEvent::TextDelta { content } = e {
633                    Some(content.as_str())
634                } else {
635                    None
636                }
637            })
638            .collect();
639        assert!(
640            !texts
641                .iter()
642                .any(|t| t.contains("<think>") || t.contains("</think>"))
643        );
644        assert!(texts.iter().any(|t| t.contains("real text")));
645    }
646}