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(dialect))`.  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                // Only an explicitly reported "stop" is corrected. An absent
207                // reason stays absent: the upstream never claimed the turn
208                // ended, so there is no wrong claim here to repair.
209                let finish_reason =
210                    if finish_reason.as_deref() == Some("stop") && self.next_index > 0 {
211                        Some("tool_calls".to_owned())
212                    } else {
213                        finish_reason
214                    };
215                self.queued
216                    .push_back(LlmStreamEvent::Done { finish_reason });
217                // Deliberately *not* `self.terminated = true` here — see the
218                // `done_forwarded` doc comment. The stream keeps polling
219                // `inner` (in `poll_next`) so a legitimate trailing `Usage`
220                // event can still be forwarded before the byte stream
221                // actually closes.
222                self.done_forwarded = true;
223            }
224        }
225    }
226}
227
228impl Stream for NormalizingStream {
229    type Item = Result<LlmStreamEvent>;
230
231    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
232        loop {
233            if let Some(ev) = self.queued.pop_front() {
234                return Poll::Ready(Some(Ok(ev)));
235            }
236            if self.terminated {
237                return Poll::Ready(None);
238            }
239            match self.inner.as_mut().poll_next(cx) {
240                Poll::Pending => return Poll::Pending,
241                Poll::Ready(Some(Ok(event))) => {
242                    self.handle_upstream(event);
243                    // Loop to drain `queued` (or poll inner again if empty).
244                }
245                Poll::Ready(Some(Err(e))) => {
246                    self.terminated = true;
247                    return Poll::Ready(Some(Err(e)));
248                }
249                Poll::Ready(None) => {
250                    // Upstream ended without a `Done`.  Flush any held-back
251                    // parser state so no bytes are lost, then end.  Skipped
252                    // when `Done` was already forwarded (e.g. only a
253                    // trailing `Usage` event followed it) — the parser was
254                    // already finalised in `handle_upstream`'s `Done` arm,
255                    // and finishing it twice would re-flush stale state.
256                    if !self.done_forwarded {
257                        let out = self.parser.finish();
258                        self.enqueue_parser_output(out);
259                    }
260                    self.terminated = true;
261                    if let Some(ev) = self.queued.pop_front() {
262                        return Poll::Ready(Some(Ok(ev)));
263                    }
264                    return Poll::Ready(None);
265                }
266            }
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::normalize::registry::{dialect_for_tags, get_parser};
275    use crate::normalize::tags;
276    use std::task::Poll;
277
278    /// Minimal hand-rolled stream that yields a fixed sequence of events.
279    struct VecStream {
280        items: VecDeque<Result<LlmStreamEvent>>,
281    }
282
283    impl VecStream {
284        fn new(items: Vec<Result<LlmStreamEvent>>) -> Self {
285            Self {
286                items: items.into(),
287            }
288        }
289    }
290
291    impl Stream for VecStream {
292        type Item = Result<LlmStreamEvent>;
293        fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
294            Poll::Ready(self.items.pop_front())
295        }
296    }
297
298    fn drain(mut s: NormalizingStream) -> Vec<LlmStreamEvent> {
299        // Poll synchronously with std's no-op waker.  Our test stream is
300        // always Ready, so we never observe Pending.
301        let waker = std::task::Waker::noop();
302        let mut cx = Context::from_waker(waker);
303        let mut out = Vec::new();
304        loop {
305            match Pin::new(&mut s).poll_next(&mut cx) {
306                Poll::Ready(Some(Ok(ev))) => out.push(ev),
307                Poll::Ready(Some(Err(e))) => panic!("unexpected error: {e}"),
308                Poll::Ready(None) => return out,
309                Poll::Pending => panic!("test stream returned Pending"),
310            }
311        }
312    }
313
314    fn wrap(events: Vec<LlmStreamEvent>, qwen: bool) -> NormalizingStream {
315        let inner: InnerStream = Box::pin(VecStream::new(events.into_iter().map(Ok).collect()));
316        let parser = if qwen {
317            get_parser(dialect_for_tags(&[tags::FORMAT_QWEN_XML.to_owned()]).as_ref())
318        } else {
319            get_parser(None)
320        };
321        NormalizingStream::new(inner, parser)
322    }
323
324    #[test]
325    fn standard_parser_is_passthrough() {
326        let events = vec![
327            LlmStreamEvent::TextDelta {
328                content: "hello".into(),
329            },
330            LlmStreamEvent::Done {
331                finish_reason: Some("stop".into()),
332            },
333        ];
334        let out = drain(wrap(events.clone(), false));
335        assert_eq!(out, events);
336    }
337
338    #[test]
339    fn usage_event_passes_through_unchanged() {
340        let events = vec![
341            LlmStreamEvent::TextDelta {
342                content: "hello".into(),
343            },
344            LlmStreamEvent::Usage {
345                prompt_tokens: 10,
346                completion_tokens: 5,
347                total_tokens: 15,
348                cached_tokens: None,
349            },
350            LlmStreamEvent::Done {
351                finish_reason: Some("stop".into()),
352            },
353        ];
354        let out = drain(wrap(events.clone(), false));
355        assert_eq!(out, events);
356    }
357
358    #[test]
359    fn usage_event_after_done_still_forwarded() {
360        // The actual llama.cpp/OpenAI wire order: the finish_reason chunk
361        // (-> Done) arrives *before* the trailing usage-only chunk
362        // (-> Usage). NormalizingStream must not treat Done as a hard
363        // stream-end that discards this legitimate trailer.
364        let events = vec![
365            LlmStreamEvent::TextDelta {
366                content: "hello".into(),
367            },
368            LlmStreamEvent::Done {
369                finish_reason: Some("stop".into()),
370            },
371            LlmStreamEvent::Usage {
372                prompt_tokens: 10,
373                completion_tokens: 5,
374                total_tokens: 15,
375                cached_tokens: None,
376            },
377        ];
378        let out = drain(wrap(events.clone(), false));
379        assert_eq!(
380            out, events,
381            "Usage arriving after Done must still be forwarded, in order"
382        );
383    }
384
385    #[test]
386    fn text_delta_after_done_is_dropped_defensively() {
387        // Malformed upstream: content arriving after Done. Must not panic
388        // or resurrect already-finalised parser state; simply dropped.
389        let events = vec![
390            LlmStreamEvent::Done {
391                finish_reason: Some("stop".into()),
392            },
393            LlmStreamEvent::TextDelta {
394                content: "should be dropped".into(),
395            },
396            LlmStreamEvent::Usage {
397                prompt_tokens: 1,
398                completion_tokens: 1,
399                total_tokens: 2,
400                cached_tokens: None,
401            },
402        ];
403        let out = drain(wrap(events, false));
404        assert_eq!(
405            out,
406            vec![
407                LlmStreamEvent::Done {
408                    finish_reason: Some("stop".into()),
409                },
410                LlmStreamEvent::Usage {
411                    prompt_tokens: 1,
412                    completion_tokens: 1,
413                    total_tokens: 2,
414                    cached_tokens: None,
415                },
416            ],
417            "stray TextDelta after Done must be dropped, Usage still forwarded"
418        );
419    }
420
421    #[test]
422    fn qwen_xml_in_text_is_extracted_to_tool_call_delta() {
423        let events = vec![
424            LlmStreamEvent::TextDelta {
425                content: r#"hi <tool_call>{"name":"foo","arguments":{"x":1}}</tool_call> done"#
426                    .into(),
427            },
428            LlmStreamEvent::Done {
429                finish_reason: Some("tool_calls".into()),
430            },
431        ];
432        let out = drain(wrap(events, true));
433        // Expect: TextDelta("hi  done"), ToolCallDelta, Done.
434        assert_eq!(out.len(), 3);
435        assert!(matches!(
436            &out[0],
437            LlmStreamEvent::TextDelta { content } if content == "hi  done"
438        ));
439        match &out[1] {
440            LlmStreamEvent::ToolCallDelta {
441                index,
442                id,
443                name,
444                arguments,
445            } => {
446                assert_eq!(*index, 0);
447                assert_eq!(id.as_deref(), Some("call_qwen_0"));
448                assert_eq!(name.as_deref(), Some("foo"));
449                assert_eq!(arguments.as_deref(), Some(r#"{"x":1}"#));
450            }
451            other => panic!("expected ToolCallDelta, got {other:?}"),
452        }
453        assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
454    }
455
456    #[test]
457    fn qwen_xml_in_reasoning_is_extracted_and_text_clean() {
458        let events = vec![
459            LlmStreamEvent::ReasoningDelta {
460                content: r#"think <tool_call>{"name":"foo","arguments":{}}</tool_call> end"#.into(),
461            },
462            LlmStreamEvent::Done {
463                finish_reason: Some("tool_calls".into()),
464            },
465        ];
466        let out = drain(wrap(events, true));
467        assert_eq!(out.len(), 3);
468        assert!(matches!(
469            &out[0],
470            LlmStreamEvent::ReasoningDelta { content } if content == "think  end"
471        ));
472        assert!(matches!(out[1], LlmStreamEvent::ToolCallDelta { .. }));
473        assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
474    }
475
476    #[test]
477    fn synthesised_index_does_not_collide_with_upstream() {
478        let events = vec![
479            LlmStreamEvent::ToolCallDelta {
480                index: 0,
481                id: Some("native".into()),
482                name: Some("nat".into()),
483                arguments: Some("{}".into()),
484            },
485            LlmStreamEvent::TextDelta {
486                content: r#"<tool_call>{"name":"foo","arguments":{}}</tool_call>"#.into(),
487            },
488            LlmStreamEvent::Done {
489                finish_reason: Some("stop".into()),
490            },
491        ];
492        let out = drain(wrap(events, true));
493        // Native delta + synthesised delta + Done = 3.
494        assert_eq!(out.len(), 3);
495        let LlmStreamEvent::ToolCallDelta { index: idx0, .. } = &out[0] else {
496            panic!()
497        };
498        let LlmStreamEvent::ToolCallDelta { index: idx1, .. } = &out[1] else {
499            panic!()
500        };
501        assert_eq!(*idx0, 0);
502        assert_eq!(*idx1, 1);
503    }
504
505    #[test]
506    fn unclosed_tag_at_done_emits_normalization_error_then_done() {
507        let events = vec![
508            LlmStreamEvent::TextDelta {
509                content: r#"<tool_call>{"name":"foo""#.into(),
510            },
511            LlmStreamEvent::Done {
512                finish_reason: Some("stop".into()),
513            },
514        ];
515        let out = drain(wrap(events, true));
516        // Expect at least: NormalizationError, Done.
517        assert!(matches!(out.last(), Some(LlmStreamEvent::Done { .. })));
518        assert!(
519            out.iter()
520                .any(|e| matches!(e, LlmStreamEvent::NormalizationError { .. }))
521        );
522    }
523
524    #[test]
525    fn upstream_ends_without_done_flushes_parser() {
526        // No Done at all — wrapper should still terminate cleanly and
527        // surface any held-back text.
528        let events = vec![LlmStreamEvent::TextDelta {
529            content: "<tool".into(),
530        }];
531        let out = drain(wrap(events, true));
532        assert_eq!(out.len(), 1);
533        assert!(matches!(
534            &out[0],
535            LlmStreamEvent::TextDelta { content } if content == "<tool"
536        ));
537    }
538
539    /// Qwen3.5 emits `tool_calls` in the stream but finishes with
540    /// `finish_reason: "stop"` instead of `"tool_calls"`.  The normalizer
541    /// must correct this so clients that gate tool dispatch on `finish_reason`
542    /// (e.g. Zed) do not hang.
543    #[test]
544    fn finish_reason_corrected_to_tool_calls_when_tool_calls_seen() {
545        let events = vec![
546            LlmStreamEvent::ToolCallDelta {
547                index: 0,
548                id: Some("call_0".into()),
549                name: Some("read_file".into()),
550                arguments: Some(r#"{"path":"/tmp/x"}"#.into()),
551            },
552            LlmStreamEvent::Done {
553                finish_reason: Some("stop".into()), // wrong — model bug
554            },
555        ];
556        let out = drain(wrap(events, false));
557        assert_eq!(out.len(), 2);
558        match &out[1] {
559            LlmStreamEvent::Done { finish_reason } => {
560                assert_eq!(finish_reason.as_deref(), Some("tool_calls"));
561            }
562            other => panic!("expected Done, got {other:?}"),
563        }
564    }
565
566    /// When no tool calls were emitted, `finish_reason: "stop"` must be
567    /// left unchanged.
568    #[test]
569    fn finish_reason_stop_unchanged_when_no_tool_calls() {
570        let events = vec![
571            LlmStreamEvent::TextDelta {
572                content: "hello".into(),
573            },
574            LlmStreamEvent::Done {
575                finish_reason: Some("stop".into()),
576            },
577        ];
578        let out = drain(wrap(events, false));
579        match &out[1] {
580            LlmStreamEvent::Done { finish_reason } => {
581                assert_eq!(finish_reason.as_deref(), Some("stop"));
582            }
583            other => panic!("expected Done, got {other:?}"),
584        }
585    }
586
587    /// Stray `</think>` closing tags emitted in text content by reasoning
588    /// models (e.g. Qwen3) must be stripped before reaching the client.
589    #[test]
590    fn stray_close_think_tag_stripped_from_text() {
591        let events = vec![
592            LlmStreamEvent::TextDelta {
593                content: "</think>\n\n".into(),
594            },
595            LlmStreamEvent::TextDelta {
596                content: "actual answer".into(),
597            },
598            LlmStreamEvent::Done {
599                finish_reason: Some("stop".into()),
600            },
601        ];
602        let out = drain(wrap(events, false));
603        // First delta should be dropped entirely (only whitespace after stripping).
604        // Second delta passes through unchanged.
605        let texts: Vec<_> = out
606            .iter()
607            .filter_map(|e| {
608                if let LlmStreamEvent::TextDelta { content } = e {
609                    Some(content.as_str())
610                } else {
611                    None
612                }
613            })
614            .collect();
615        assert!(
616            !texts.iter().any(|t| t.contains("</think>")),
617            "found </think> in output: {texts:?}"
618        );
619        assert!(texts.iter().any(|t| t.contains("actual answer")));
620    }
621
622    /// `<think>` open tags should also be stripped from text content.
623    #[test]
624    fn stray_open_think_tag_stripped_from_text() {
625        let events = vec![
626            LlmStreamEvent::TextDelta {
627                content: "<think>spurious</think>real text".into(),
628            },
629            LlmStreamEvent::Done {
630                finish_reason: Some("stop".into()),
631            },
632        ];
633        let out = drain(wrap(events, false));
634        let texts: Vec<_> = out
635            .iter()
636            .filter_map(|e| {
637                if let LlmStreamEvent::TextDelta { content } = e {
638                    Some(content.as_str())
639                } else {
640                    None
641                }
642            })
643            .collect();
644        assert!(
645            !texts
646                .iter()
647                .any(|t| t.contains("<think>") || t.contains("</think>"))
648        );
649        assert!(texts.iter().any(|t| t.contains("real text")));
650    }
651}