Skip to main content

runifold_model/
invocation.rs

1use std::{future::Future, pin::Pin, time::Duration};
2
3use futures_core::Stream;
4use futures_util::{
5    StreamExt,
6    future::{Either, select},
7};
8use runifold_core::{CancellationToken, Instant, InvocationId, RunContext, RunId};
9
10use crate::{
11    ModelCapabilities, ModelError, ModelErrorKind, ModelRef, ModelRequest, ModelResponse,
12    ModelStreamAccumulator, ModelStreamEvent,
13};
14
15/// A boxed, sendable future returned by a model implementation.
16#[cfg(not(target_arch = "wasm32"))]
17pub type ModelFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
18
19/// A boxed future returned by a model implementation on single-threaded WASM.
20#[cfg(target_arch = "wasm32")]
21pub type ModelFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
22
23/// A provider-neutral stream of canonical model events.
24#[cfg(not(target_arch = "wasm32"))]
25pub type ModelEventStream =
26    Pin<Box<dyn Stream<Item = Result<ModelStreamEvent, ModelError>> + Send + 'static>>;
27
28/// A provider-neutral model stream on single-threaded WASM.
29#[cfg(target_arch = "wasm32")]
30pub type ModelEventStream =
31    Pin<Box<dyn Stream<Item = Result<ModelStreamEvent, ModelError>> + 'static>>;
32
33/// Execution scope for one model invocation.
34///
35/// It deliberately carries lifecycle data rather than provider configuration.
36/// Adapters must observe cancellation and should translate the deadline into
37/// their transport's timeout mechanism.
38#[derive(Clone, Debug)]
39pub struct ModelCallContext {
40    invocation_id: InvocationId,
41    run_id: Option<RunId>,
42    deadline: Option<Instant>,
43    cancellation: CancellationToken,
44}
45
46impl ModelCallContext {
47    /// Creates a standalone invocation context.
48    pub fn new() -> Self {
49        Self {
50            invocation_id: InvocationId::new(),
51            run_id: None,
52            deadline: None,
53            cancellation: CancellationToken::new(),
54        }
55    }
56
57    /// Creates an invocation scoped beneath a run.
58    pub fn for_run(run: &RunContext) -> Self {
59        Self {
60            invocation_id: InvocationId::new(),
61            run_id: Some(run.run_id()),
62            deadline: run.deadline(),
63            cancellation: run.cancellation().child_token(),
64        }
65    }
66
67    /// Returns this model invocation's identity.
68    pub const fn invocation_id(&self) -> InvocationId {
69        self.invocation_id
70    }
71
72    /// Returns the owning run identity, when invoked inside a run.
73    pub const fn run_id(&self) -> Option<RunId> {
74        self.run_id
75    }
76
77    /// Returns the effective deadline.
78    pub const fn deadline(&self) -> Option<Instant> {
79        self.deadline
80    }
81
82    /// Returns the remaining time before the deadline.
83    pub fn remaining(&self) -> Option<Duration> {
84        self.deadline
85            .map(|deadline| deadline.saturating_duration_since(Instant::now()))
86    }
87
88    /// Returns the invocation's hierarchical cancellation token.
89    pub const fn cancellation(&self) -> &CancellationToken {
90        &self.cancellation
91    }
92
93    /// Sets a deadline, retaining an existing earlier deadline.
94    #[must_use]
95    pub fn with_deadline(mut self, deadline: Instant) -> Self {
96        self.deadline = Some(
97            self.deadline
98                .map_or(deadline, |current| current.min(deadline)),
99        );
100        self
101    }
102
103    /// Replaces the cancellation root for an externally scoped invocation.
104    ///
105    /// The invocation receives a child token so provider attempts cannot
106    /// cancel their caller's broader operation.
107    #[must_use]
108    pub fn with_cancellation(mut self, cancellation: &CancellationToken) -> Self {
109        self.cancellation = cancellation.child_token();
110        self
111    }
112
113    /// Creates a distinct provider-attempt context under the same logical
114    /// invocation scope.
115    ///
116    /// The attempt receives a new invocation identity while inheriting the
117    /// run, effective deadline, and hierarchical cancellation.
118    #[must_use]
119    pub fn child_attempt(&self) -> Self {
120        Self {
121            invocation_id: InvocationId::new(),
122            run_id: self.run_id,
123            deadline: self.deadline,
124            cancellation: self.cancellation.child_token(),
125        }
126    }
127}
128
129impl Default for ModelCallContext {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135/// Object-safe boundary implemented by model provider adapters.
136///
137/// Streaming is the source of truth. [`Model::invoke`] is a canonical
138/// collector over that stream, so streamed and non-streamed calls cannot
139/// silently develop different normalization behavior.
140pub trait Model: Send + Sync {
141    /// Resolves capabilities for a provider-qualified model.
142    fn capabilities<'a>(
143        &'a self,
144        model: &'a ModelRef,
145    ) -> ModelFuture<'a, Result<ModelCapabilities, ModelError>>;
146
147    /// Opens a canonical event stream for a request.
148    fn stream(
149        &self,
150        request: ModelRequest,
151        context: ModelCallContext,
152    ) -> ModelFuture<'_, Result<ModelEventStream, ModelError>>;
153
154    /// Invokes the model and reconstructs its terminal response.
155    fn invoke(
156        &self,
157        request: ModelRequest,
158        context: ModelCallContext,
159    ) -> ModelFuture<'_, Result<ModelResponse, ModelError>> {
160        Box::pin(async move {
161            let cancellation = context.cancellation().clone();
162            let stream_future = self.stream(request, context);
163            let mut stream =
164                match select(Box::pin(cancellation.cancelled()), Box::pin(stream_future)).await {
165                    Either::Left(_) => return Err(cancelled_error()),
166                    Either::Right((result, _)) => result?,
167                };
168
169            let mut accumulator = ModelStreamAccumulator::new();
170            loop {
171                let next = stream.next();
172                match select(Box::pin(cancellation.cancelled()), Box::pin(next)).await {
173                    Either::Left(_) => return Err(cancelled_error()),
174                    Either::Right((Some(event), _)) => {
175                        if let Some(response) = accumulator.push(event?)? {
176                            return Ok(response);
177                        }
178                    }
179                    Either::Right((None, _)) => {
180                        return Err(ModelError::local(
181                            ModelErrorKind::Protocol,
182                            "model stream ended before a terminal response event",
183                        ));
184                    }
185                }
186            }
187        })
188    }
189}
190
191/// Stable provider identity carried by a concrete model adapter.
192///
193/// Implementing this trait in addition to [`Model`] lets higher runtime layers
194/// construct provider-qualified model references and attach Agent, routing,
195/// retry, circuit-breaker, observability, budget, and workflow behavior
196/// without provider-specific orchestration code.
197pub trait ProviderModel: Model {
198    /// Returns the canonical provider namespace used by this adapter.
199    fn provider(&self) -> &str;
200
201    /// Qualifies one model name with this adapter's provider identity.
202    fn model_ref(&self, model: impl Into<String>) -> ModelRef
203    where
204        Self: Sized,
205    {
206        ModelRef::new(self.provider(), model)
207    }
208}
209
210fn cancelled_error() -> ModelError {
211    ModelError::local(ModelErrorKind::Cancelled, "model invocation was cancelled")
212}