Skip to main content

ferrin_core/stream_text/
builder.rs

1//! The `stream_text` builder.
2
3use futures_util::FutureExt;
4use std::fmt;
5use std::future::Future;
6use std::future::IntoFuture;
7use std::panic::AssertUnwindSafe;
8use std::panic::catch_unwind;
9use std::sync::Arc;
10use std::sync::Mutex;
11
12use ferrin_spec::BoxFuture;
13use ferrin_spec::LanguageModelRef;
14
15use super::StreamErrorInfo;
16use super::StreamEvent;
17use super::pipeline;
18use super::result::StreamTextResult;
19use super::transforms::StreamTransform;
20use crate::error::Error;
21use crate::generate_text::config::CallConfig;
22use crate::hooks::HookFn;
23use crate::output::NoOutput;
24use crate::output::Output;
25use crate::output::OutputHandler;
26use crate::telemetry::AbortEvent;
27
28/// What the pipeline does after an error reported by the model stream.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30#[non_exhaustive]
31pub enum ErrorDecision {
32    /// Report the error and end the call.
33    #[default]
34    Continue,
35    /// Retry the model call of the current step. Honoured at most once per
36    /// step and only when [`StreamText::stream_retries`] is configured.
37    Retry,
38}
39
40/// Callback invoked for every error that occurs while streaming.
41///
42/// Implemented for every `Fn(StreamErrorInfo) -> impl Future<Output =
43/// ErrorDecision>` closure.
44pub trait OnErrorFn: Send + Sync + 'static {
45    /// Handles one error and decides how to proceed.
46    fn call(&self, error: StreamErrorInfo) -> BoxFuture<'static, ErrorDecision>;
47}
48
49impl<F, Fut> OnErrorFn for F
50where
51    F: Fn(StreamErrorInfo) -> Fut + Send + Sync + 'static,
52    Fut: Future<Output = ErrorDecision> + Send + 'static,
53{
54    fn call(&self, error: StreamErrorInfo) -> BoxFuture<'static, ErrorDecision> {
55        Box::pin(self(error))
56    }
57}
58
59/// Streaming-only settings.
60#[derive(Default)]
61pub(crate) struct StreamConfig {
62    pub(crate) transforms: Vec<Arc<dyn StreamTransform>>,
63    pub(crate) include_raw_chunks: bool,
64    pub(crate) stream_retries: Option<u32>,
65    pub(crate) on_error: Option<Arc<dyn OnErrorFn>>,
66    handled_errors: Mutex<Vec<StreamErrorInfo>>,
67}
68
69impl StreamConfig {
70    pub(crate) fn mark_error_handled(&self, error: StreamErrorInfo) {
71        self.handled_errors
72            .lock()
73            .unwrap_or_else(std::sync::PoisonError::into_inner)
74            .push(error);
75    }
76
77    pub(crate) fn take_error_handled(&self, error: &StreamErrorInfo) -> bool {
78        let mut handled = self
79            .handled_errors
80            .lock()
81            .unwrap_or_else(std::sync::PoisonError::into_inner);
82        if let Some(index) = handled.iter().position(|item| item == error) {
83            handled.remove(index);
84            true
85        } else {
86            false
87        }
88    }
89
90    pub(crate) async fn error_decision(&self, error: StreamErrorInfo) -> ErrorDecision {
91        let Some(callback) = &self.on_error else {
92            return ErrorDecision::Continue;
93        };
94        let Ok(future) = catch_unwind(AssertUnwindSafe(|| callback.call(error))) else {
95            return ErrorDecision::Continue;
96        };
97        AssertUnwindSafe(future)
98            .catch_unwind()
99            .await
100            .unwrap_or(ErrorDecision::Continue)
101    }
102}
103
104impl fmt::Debug for StreamConfig {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.debug_struct("StreamConfig")
107            .field("transforms", &self.transforms.len())
108            .field("include_raw_chunks", &self.include_raw_chunks)
109            .field("stream_retries", &self.stream_retries)
110            .field("on_error", &self.on_error.is_some())
111            .finish()
112    }
113}
114
115/// Starts building a streaming text generation call.
116///
117/// The builder is a future: `.await` starts the pipeline and resolves once
118/// the first model request has been established (or failed after retries).
119#[must_use]
120pub fn stream_text(model: impl Into<LanguageModelRef>) -> StreamText<()> {
121    StreamText {
122        config: CallConfig::new(model.into()),
123        output: Arc::new(NoOutput),
124        stream: StreamConfig {
125            transforms: Vec::new(),
126            include_raw_chunks: false,
127            stream_retries: None,
128            on_error: None,
129            handled_errors: Mutex::new(Vec::new()),
130        },
131    }
132}
133
134/// Builder and future of a `stream_text` call.
135pub struct StreamText<O> {
136    pub(crate) config: CallConfig,
137    pub(crate) output: Arc<dyn OutputHandler<O>>,
138    pub(crate) stream: StreamConfig,
139}
140
141crate::builder::impl_call_builder!(StreamText);
142
143impl<O> StreamText<O> {
144    /// Requests structured output parsed by `output`.
145    pub fn output<T>(self, output: Output<T>) -> StreamText<T> {
146        StreamText {
147            config: self.config,
148            output: output.handler(),
149            stream: self.stream,
150        }
151    }
152
153    /// Appends a transform applied to the event stream before the step
154    /// results are accumulated. Transforms run in order.
155    #[must_use]
156    pub fn transform(mut self, transform: impl StreamTransform + 'static) -> Self {
157        self.stream.transforms.push(Arc::new(transform));
158        self
159    }
160
161    /// Forwards raw provider chunks as [`StreamEvent::Raw`].
162    #[must_use]
163    pub fn include_raw_chunks(mut self) -> Self {
164        self.stream.include_raw_chunks = true;
165        self
166    }
167
168    /// Enables stream-level retries: after an error reported by the model
169    /// stream the current step is retried up to `retries` times. `0` allows
170    /// only retries requested by the [`on_error`](Self::on_error) callback.
171    /// Retries are disabled when this is not called.
172    #[must_use]
173    pub fn stream_retries(mut self, retries: u32) -> Self {
174        self.stream.stream_retries = Some(retries);
175        self
176    }
177
178    /// Observes transformed events and provider errors considered for retry.
179    ///
180    /// A provider error is observed before `on_error`, including errors
181    /// swallowed by a successful retry. Retry boundaries bypass this hook;
182    /// a terminal provider error is not reported twice after transforms.
183    #[must_use]
184    pub fn on_chunk(mut self, f: impl HookFn<StreamEvent>) -> Self {
185        self.config.hooks.on_chunk.push(Arc::new(f));
186        self
187    }
188
189    /// Runs when the call is aborted by cancellation.
190    #[must_use]
191    pub fn on_abort(mut self, f: impl HookFn<AbortEvent>) -> Self {
192        self.config.hooks.on_abort.push(Arc::new(f));
193        self
194    }
195
196    /// Runs for every error that occurs while streaming; the returned
197    /// decision may request a retry (see [`stream_retries`](Self::stream_retries)).
198    /// Synchronous and asynchronous callback panics are ignored, preserving
199    /// the original provider failure and configured automatic retry budget.
200    #[must_use]
201    pub fn on_error(mut self, f: impl OnErrorFn) -> Self {
202        self.stream.on_error = Some(Arc::new(f));
203        self
204    }
205}
206
207impl<O> fmt::Debug for StreamText<O> {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        f.debug_struct("StreamText")
210            .field("config", &self.config)
211            .field("stream", &self.stream)
212            .finish_non_exhaustive()
213    }
214}
215
216impl<O: Send + 'static> IntoFuture for StreamText<O> {
217    type Output = Result<StreamTextResult<O>, Error>;
218    type IntoFuture = BoxFuture<'static, Self::Output>;
219
220    fn into_future(self) -> Self::IntoFuture {
221        Box::pin(async move {
222            self.output.validate_configuration()?;
223            pipeline::start(self.config, self.output, self.stream).await
224        })
225    }
226}