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}