Skip to main content

lc_core/structured_output/
streaming.rs

1// src/core/structured_output/streaming.rs
2//! Streaming structured output support for incremental LLM response parsing.
3
4use async_trait::async_trait;
5use futures_util::Stream;
6use futures_util::StreamExt;
7use serde::de::DeserializeOwned;
8use serde::Serialize;
9use serde_json::Value;
10use std::pin::Pin;
11
12use crate::language_models::BaseChatModel;
13use lc_schema::Message;
14
15use super::extract::{build_structured_system_prompt, StructuredOutputError};
16use super::parser::{PartialJsonError, PartialJsonParser};
17
18/// Trait that extends `BaseChatModel` with streaming structured output capabilities.
19///
20/// Provides a streaming variant of `with_structured_output` that yields partial
21/// `T` values as the model generates tokens. The target type `T` should derive
22/// `#[serde(default)]` or use `Option` fields so that partial JSON can be
23/// deserialized with missing fields filled by defaults.
24#[async_trait]
25pub trait StreamingStructuredOutputExt: BaseChatModel {
26    /// Stream structured output from a chat model.
27    ///
28    /// Returns a stream of `T` values. Each item represents the best partial
29    /// result that could be parsed from the tokens received so far. The final
30    /// item in the stream is the complete result.
31    ///
32    /// # Arguments
33    ///
34    /// * `schema` - A JSON Schema describing the expected output shape.
35    /// * `prompt` - The user prompt to send to the LLM.
36    ///
37    /// # Returns
38    ///
39    /// A stream of `Result<T, StructuredOutputError>` items.
40    async fn stream_structured_output<T>(
41        &self,
42        schema: Value,
43        prompt: &str,
44    ) -> Result<
45        Pin<Box<dyn Stream<Item = Result<T, StructuredOutputError>> + Send>>,
46        StructuredOutputError,
47    >
48    where
49        T: DeserializeOwned + Serialize + Clone + PartialEq + Unpin + Send + Sync + 'static,
50    {
51        stream_structured_output(self, schema, prompt).await
52    }
53}
54
55/// Blanket implementation: every `BaseChatModel` automatically gets
56/// `StreamingStructuredOutputExt`.
57impl<M: BaseChatModel> StreamingStructuredOutputExt for M {}
58
59/// Stream structured output from a chat model.
60///
61/// Returns a stream of partial `T` values as the model generates tokens.
62/// The implementation:
63/// 1. Calls `stream_chat` with a prompt that asks for JSON matching the schema
64/// 2. Accumulates tokens through the `PartialJsonParser`
65/// 3. At each successful parse, yields a `T` value (partial fields filled by
66///    serde defaults, rest default)
67/// 4. On stream end, yields the final complete `T`
68///
69/// # Arguments
70///
71/// * `llm` - Any type implementing `BaseChatModel`.
72/// * `schema` - A JSON Schema describing the expected output.
73/// * `prompt` - The user prompt to send to the LLM.
74///
75/// # Returns
76///
77/// A `Result` containing a stream of `Result<T, StructuredOutputError>` items,
78/// or a `StructuredOutputError` if the stream could not be set up.
79///
80/// # Type requirements
81///
82/// The target type `T` should use `#[serde(default)]` or `Option` fields so
83/// that partial JSON can be deserialized with missing fields filled by defaults.
84/// If `T` does not support default deserialization, partial results will fail
85/// and only the final complete result will be yielded.
86///
87/// # Example
88///
89/// ```ignore
90/// use serde::{Deserialize, Serialize};
91/// use langchainrust::core::structured_output::stream_structured_output;
92/// use futures_util::StreamExt;
93///
94/// #[derive(Debug, Deserialize, Serialize, Clone)]
95/// #[serde(default)]
96/// struct Person {
97///     name: String,
98///     age: u32,
99/// }
100///
101/// impl Default for Person {
102///     fn default() -> Self {
103///         Self { name: String::new(), age: 0 }
104///     }
105/// }
106///
107/// let mut stream = stream_structured_output::<Person, _>(
108///     &llm, schema, "Tell me about Alice"
109/// ).await?;
110/// while let Some(result) = stream.next().await {
111///     let person = result?;
112///     println!("Partial: name={}, age={}", person.name, person.age);
113/// }
114/// ```
115pub async fn stream_structured_output<T, M>(
116    llm: &M,
117    schema: Value,
118    prompt: &str,
119) -> Result<
120    Pin<Box<dyn Stream<Item = Result<T, StructuredOutputError>> + Send>>,
121    StructuredOutputError,
122>
123where
124    T: DeserializeOwned + Serialize + Clone + PartialEq + Unpin + Send + Sync + 'static,
125    M: BaseChatModel + ?Sized,
126{
127    // Validate the schema is an object
128    if !schema.is_object() {
129        return Err(StructuredOutputError::SchemaError(format!(
130            "Schema must be a JSON object, got: {}",
131            schema
132        )));
133    }
134
135    // Build the system prompt with schema and format instructions
136    let system_prompt = build_structured_system_prompt(&schema);
137
138    let messages = vec![Message::system(system_prompt), Message::human(prompt)];
139
140    // Start the stream. We need to erase the model's error type by mapping
141    // it to StructuredOutputError before passing to the stream processor.
142    let token_stream = llm
143        .stream_chat(messages, None)
144        .await
145        .map_err(|e| StructuredOutputError::LLMError(e.to_string()))?;
146
147    // Map the inner stream: each chunk's text delta becomes the token fed to
148    // the partial-JSON parser, and the error type is erased to
149    // StructuredOutputError.
150    let mapped_stream = token_stream.map(|item| {
151        item.map(|chunk| chunk.text)
152            .map_err(|e| StructuredOutputError::LLMError(e.to_string()))
153    });
154
155    // Box the mapped stream so it has a concrete type for StructuredStreamProcessor
156    let boxed: Pin<Box<dyn Stream<Item = Result<String, StructuredOutputError>> + Send>> =
157        Box::pin(mapped_stream);
158
159    let output_stream = StructuredStreamProcessor::<T>::new(boxed);
160
161    Ok(Box::pin(output_stream))
162}
163
164/// Stream processor that accumulates LLM tokens through a `PartialJsonParser`
165/// and yields partial `T` values.
166///
167/// This is implemented as a manual `Stream` rather than using `stream::unfold`
168/// because we need to maintain mutable state (the `PartialJsonParser`) across
169/// stream polls.
170struct StructuredStreamProcessor<T> {
171    inner: Pin<Box<dyn Stream<Item = Result<String, StructuredOutputError>> + Send>>,
172    parser: PartialJsonParser,
173    last_value: Option<T>,
174    done: bool,
175}
176
177// Safety: StructuredStreamProcessor is Unpin when T is Unpin because all fields
178// are Unpin: Pin<Box<dyn ..>> is Unpin, PartialJsonParser is Unpin,
179// Option<T> is Unpin when T is Unpin, and bool is Unpin.
180impl<T: Unpin> Unpin for StructuredStreamProcessor<T> {}
181
182impl<T> StructuredStreamProcessor<T>
183where
184    T: DeserializeOwned + Serialize + Clone + PartialEq + Unpin + Send + Sync + 'static,
185{
186    fn new(
187        inner: Pin<Box<dyn Stream<Item = Result<String, StructuredOutputError>> + Send>>,
188    ) -> Self {
189        Self {
190            inner,
191            parser: PartialJsonParser::new(),
192            last_value: None,
193            done: false,
194        }
195    }
196}
197
198impl<T> Stream for StructuredStreamProcessor<T>
199where
200    T: DeserializeOwned + Serialize + Clone + PartialEq + Send + Sync + Unpin + 'static,
201{
202    type Item = Result<T, StructuredOutputError>;
203
204    fn poll_next(
205        self: Pin<&mut Self>,
206        cx: &mut std::task::Context<'_>,
207    ) -> std::task::Poll<Option<Self::Item>> {
208        // Safety: StructuredStreamProcessor is Unpin because all its fields are Unpin.
209        // Pin<Box<dyn Stream + Send>> is Unpin, PartialJsonParser is Unpin,
210        // Option<T> is Unpin, and bool is Unpin.
211        let this = self.get_mut();
212
213        if this.done {
214            return std::task::Poll::Ready(None);
215        }
216
217        loop {
218            match this.inner.as_mut().poll_next(cx) {
219                std::task::Poll::Ready(Some(Ok(token))) => {
220                    match this.parser.push_and_parse(&token) {
221                        Ok(json_value) => match serde_json::from_value::<T>(json_value) {
222                            Ok(value) => {
223                                this.last_value = Some(value.clone());
224                                return std::task::Poll::Ready(Some(Ok(value)));
225                            }
226                            Err(_) => {
227                                // Partial JSON parsed but cannot deserialize into T yet.
228                                // Continue accumulating tokens.
229                                continue;
230                            }
231                        },
232                        Err(PartialJsonError::Incomplete(_)) => {
233                            // Not enough JSON yet, continue accumulating
234                            continue;
235                        }
236                        Err(PartialJsonError::Invalid(msg)) => {
237                            // The accumulated buffer is invalid JSON even after repair.
238                            // This can happen with garbage tokens; skip and continue.
239                            // We don't terminate the stream for a single bad parse.
240                            log::warn!("structured output stream hit invalid JSON fragment (skipped, continuing to accumulate): {msg}");
241                            continue;
242                        }
243                    }
244                }
245                std::task::Poll::Ready(Some(Err(e))) => {
246                    // Error from the underlying token stream
247                    return std::task::Poll::Ready(Some(Err(e)));
248                }
249                std::task::Poll::Ready(None) => {
250                    // Stream ended. Try to finalize the parser.
251                    this.done = true;
252
253                    // Take ownership of the parser to finalize it
254                    let parser = std::mem::take(&mut this.parser);
255
256                    match parser.finalize() {
257                        Ok(json_value) => match serde_json::from_value::<T>(json_value) {
258                            Ok(value) => {
259                                // Only yield if this is a new value different from last
260                                let is_new = this.last_value.as_ref() != Some(&value);
261                                if is_new {
262                                    return std::task::Poll::Ready(Some(Ok(value)));
263                                }
264                                return std::task::Poll::Ready(None);
265                            }
266                            Err(e) => {
267                                return std::task::Poll::Ready(Some(Err(
268                                    StructuredOutputError::ParseError(format!(
269                                        "Failed to deserialize final JSON into target type: {}",
270                                        e
271                                    )),
272                                )));
273                            }
274                        },
275                        Err(PartialJsonError::Invalid(msg)) => {
276                            // If we had a last_value, the stream was still "successful"
277                            // in yielding partial results, but the final buffer is invalid.
278                            // This can happen if the LLM appended non-JSON text.
279                            if this.last_value.is_some() {
280                                log::warn!("structured output stream ended with invalid buffer (already returned earlier partial results): {msg}");
281                                return std::task::Poll::Ready(None);
282                            }
283                            return std::task::Poll::Ready(Some(Err(
284                                StructuredOutputError::StreamIncomplete(msg),
285                            )));
286                        }
287                        Err(PartialJsonError::Incomplete(msg)) => {
288                            if this.last_value.is_some() {
289                                log::warn!(
290                                    "structured output stream ended with incomplete buffer (already returned earlier partial results): {msg}"
291                                );
292                                return std::task::Poll::Ready(None);
293                            }
294                            return std::task::Poll::Ready(Some(Err(
295                                StructuredOutputError::StreamIncomplete(msg),
296                            )));
297                        }
298                    }
299                }
300                std::task::Poll::Pending => {
301                    return std::task::Poll::Pending;
302                }
303            }
304        }
305    }
306}
307
308/// Attempt to deserialize a `serde_json::Value` into `T`, returning `None`
309/// if deserialization fails (e.g., missing required fields).
310///
311/// Only used from `#[cfg(test)]` test helpers, so compile it in for tests only.
312#[cfg(test)]
313pub(crate) fn try_deserialize_partial<T: DeserializeOwned>(value: Value) -> Option<T> {
314    serde_json::from_value::<T>(value).ok()
315}