rig_core/driver/dyn_model.rs
1//! A model erased to its operation: what a consumer stores when it holds
2//! any model of one operation without naming its wire and transport. A
3//! [`DynModel`] runs the same driver as the [`Model`] it was made from.
4
5use std::fmt;
6use std::sync::Arc;
7
8use super::{Model, Sending, Transport};
9use crate::error::ProviderError;
10use crate::observe::AdapterContext;
11use crate::streaming::Streamed;
12use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
13use crate::wire::{Capabilities, Descriptor, Mode, Operation, Wire};
14
15/// Object-safe mirror of the calls a [`Model`] answers, with the wire and
16/// transport fixed. Private: the only way to reach it is through
17/// [`DynModel`], which re-exposes the public surface.
18pub(crate) trait ErasedModel<Op: Operation>: WasmCompatSend + WasmCompatSync {
19 fn describe(&self) -> Descriptor<'_>;
20
21 fn open(
22 &self,
23 request: Op::Request,
24 mode: Mode,
25 sending: Sending,
26 ) -> Result<Streamed<Op>, ProviderError>;
27
28 fn dry_run(&self, request: Op::Request) -> Result<(), ProviderError>;
29}
30
31impl<W, T> ErasedModel<W::Op> for Model<W, T>
32where
33 W: Wire,
34 T: Transport<W>,
35{
36 fn describe(&self) -> Descriptor<'_> {
37 self.wire.describe()
38 }
39
40 fn open(
41 &self,
42 request: <W::Op as Operation>::Request,
43 mode: Mode,
44 sending: Sending,
45 ) -> Result<Streamed<W::Op>, ProviderError> {
46 Model::open(self, request, mode, sending)
47 }
48
49 fn dry_run(&self, request: <W::Op as Operation>::Request) -> Result<(), ProviderError> {
50 Model::dry_run(self, request)
51 }
52}
53
54/// A model of one operation with its wire and transport erased. Clones share
55/// the model. Built with [`Model::erase`] or `From<Model<W, T>>`, so a
56/// consumer takes `impl Into<DynModel<Op>>` and accepts either.
57///
58/// Every call runs the driver the concrete model runs: spans, request ids,
59/// the operation's fold and error enrichment are the same.
60///
61/// A model used by one consumer is passed as is; a model shared by several
62/// is erased once and the handle is cloned. A model a handler table serves
63/// is a `ModelHandle` in the agent runtime instead: its calls are
64/// dispatched, recorded and observed there.
65///
66/// ```no_run
67/// use rig_core::embeddings::EmbeddingsBuilder;
68/// use rig_core::vector_store::in_memory_store::InMemoryVectorStore;
69/// use rig_core::{Model, providers::openai::{self, OpenAIConfig}};
70///
71/// # async fn example(http: rig_core::http_client::DynHttpClient) -> Result<(), Box<dyn std::error::Error>> {
72/// let model = OpenAIConfig::from_env()?.connect(http).embedding(openai::TEXT_EMBEDDING_3_SMALL, None).erase();
73/// let embeddings = EmbeddingsBuilder::new(model.clone())
74/// .documents(["a document".to_owned()])?
75/// .build()
76/// .await?;
77/// let index = InMemoryVectorStore::from_documents(embeddings).index(model);
78/// # let _ = index;
79/// # Ok(())
80/// # }
81/// ```
82pub struct DynModel<Op: Operation> {
83 inner: Arc<dyn ErasedModel<Op>>,
84}
85
86impl<W, T> Model<W, T>
87where
88 W: Wire,
89 T: Transport<W>,
90{
91 /// Erase this model to its operation.
92 pub fn erase(self) -> DynModel<W::Op> {
93 DynModel {
94 inner: Arc::new(self),
95 }
96 }
97}
98
99impl<W, T> From<Model<W, T>> for DynModel<W::Op>
100where
101 W: Wire,
102 T: Transport<W>,
103{
104 fn from(model: Model<W, T>) -> Self {
105 model.erase()
106 }
107}
108
109impl<Op: Operation> Clone for DynModel<Op> {
110 fn clone(&self) -> Self {
111 Self {
112 inner: Arc::clone(&self.inner),
113 }
114 }
115}
116
117impl<Op: Operation> fmt::Debug for DynModel<Op> {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.debug_struct("DynModel")
120 .field("name", &self.name())
121 .field("id", &self.id())
122 .finish()
123 }
124}
125
126impl<Op: Operation> DynModel<Op> {
127 /// The wire's provider descriptor name (`"anthropic"`).
128 pub fn name(&self) -> &str {
129 self.inner.describe().name
130 }
131
132 /// The model id the wire addresses, when the operation addresses one.
133 pub fn id(&self) -> Option<&str> {
134 self.inner.describe().model
135 }
136
137 /// The catalog facts of the model the wire addresses: the spec it was
138 /// connected to, else the catalog's entry for its id. `None` for an
139 /// operation that addresses no model, or a model the catalog does not
140 /// list.
141 pub fn spec(&self) -> Option<&crate::catalog::ModelSpec> {
142 self.inner.describe().spec()
143 }
144
145 /// What a runtime accounts for about this model.
146 pub fn capabilities(&self) -> Capabilities {
147 self.inner.describe().capabilities
148 }
149
150 /// Send `request` and fold the whole reply into the operation's
151 /// response; [`Model::call`] with the model erased. The call owns what
152 /// it sends, so it can be spawned.
153 pub fn call(
154 &self,
155 request: impl Into<Op::Request>,
156 ) -> impl Future<Output = Result<Op::Response, ProviderError>> + WasmCompatSend + 'static {
157 self.finished(request.into(), Sending::default())
158 }
159
160 /// [`Self::call`], with the attempt observed under `observation`.
161 pub fn call_observed(
162 &self,
163 request: impl Into<Op::Request>,
164 observation: AdapterContext,
165 ) -> impl Future<Output = Result<Op::Response, ProviderError>> + WasmCompatSend + 'static {
166 self.finished(request.into(), Sending::observed(observation))
167 }
168
169 /// The call opens when first polled, as [`Model::call`] does.
170 pub(crate) fn finished(
171 &self,
172 request: Op::Request,
173 sending: Sending,
174 ) -> impl Future<Output = Result<Op::Response, ProviderError>> + WasmCompatSend + 'static {
175 let inner = self.inner.clone();
176 async move { inner.open(request, Mode::Unary, sending)?.finish().await }
177 }
178
179 /// Open a streamed reply; [`Model::stream`] with the model erased.
180 pub fn stream(&self, request: impl Into<Op::Request>) -> Result<Streamed<Op>, ProviderError> {
181 self.opened(request.into(), Sending::default())
182 }
183
184 /// [`Self::stream`], with the attempt observed under `observation`.
185 pub fn stream_observed(
186 &self,
187 request: impl Into<Op::Request>,
188 observation: AdapterContext,
189 ) -> Result<Streamed<Op>, ProviderError> {
190 self.opened(request.into(), Sending::observed(observation))
191 }
192
193 /// Open a streamed reply, sent as `sending` says.
194 pub(crate) fn opened(
195 &self,
196 request: Op::Request,
197 sending: Sending,
198 ) -> Result<Streamed<Op>, ProviderError> {
199 self.inner.open(request, Mode::Streaming, sending)
200 }
201}
202
203impl DynModel<crate::operation::Completion> {
204 /// Every option of `request` this model would refuse, with nothing
205 /// sent; [`Model::check`] with the model erased.
206 ///
207 /// ```no_run
208 /// use rig_core::catalog::Catalog;
209 /// use rig_core::completion::{CheckError, CompletionRequest, Effort};
210 /// use rig_core::providers::registry::ConnectOptions;
211 ///
212 /// let model = Catalog::builtin().connect_with("openai/gpt-6-sol", ConnectOptions::new())?;
213 /// let request = CompletionRequest::new("hi")
214 /// .temperature(0.2)
215 /// .reasoning(Effort::High);
216 /// if let Err(CheckError::Unsupported(refused)) = model.check(&request) {
217 /// for refusal in refused {
218 /// println!("{refusal}");
219 /// }
220 /// }
221 /// # Ok::<(), Box<dyn std::error::Error>>(())
222 /// ```
223 ///
224 /// # Errors
225 ///
226 /// As [`Model::check`].
227 pub fn check(
228 &self,
229 request: &crate::completion::CompletionRequest,
230 ) -> Result<(), crate::completion::CheckError> {
231 super::check_with(request, |request| self.inner.dry_run(request))
232 }
233}
234
235#[cfg(test)]
236mod tests;