Skip to main content

rig_core/catalog/
connector.rs

1//! How a catalog's models are reached: through a provider registered for
2//! their vendor, else with the environment's keys, else through a sign-in,
3//! such as a subscription plan's. Every HTTP client a model is reached
4//! through can be wrapped, such as to log or rotate keys.
5
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::fmt;
9use std::sync::Arc;
10
11use super::{Catalog, ModelSpec};
12use crate::http_client::DynHttpClient;
13use crate::operation::Completion;
14use crate::providers::registry::{ConnectError, ConnectOptions, ProviderId};
15use crate::serve::ErasedHandler;
16use crate::serve::adapters::ModelAdapter;
17use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
18
19/// A way to reach catalog models no key is set for, such as a
20/// subscription plan signed in to. A [`Connector`] asks it for a model only
21/// when the provider registered for the model's vendor, else the
22/// environment, has no key for it.
23pub trait SignIn: WasmCompatSend + WasmCompatSync + 'static {
24    /// The handler serving `spec` with the signed-in credential, or `None`
25    /// when this sign-in does not serve `spec` now. A handler that sends
26    /// HTTP sends through `transport`.
27    fn handler(
28        &self,
29        spec: &ModelSpec,
30        transport: &Transport,
31    ) -> Option<Result<ErasedHandler, ConnectError>>;
32    /// The name of the subscription plan through which this sign-in would
33    /// serve `spec`, such as `"ChatGPT"`, whether or not the user is signed
34    /// in; for model pickers.
35    fn plan(&self, spec: &ModelSpec) -> Option<&'static str>;
36}
37
38/// How a [`Connector`] reaches every model of one vendor, in place of the
39/// registry's preset and the environment: a vendor declared at run time
40/// ([`ProviderId::declare`]) on a gateway of its own, a built-in vendor at
41/// another address, or a scripted model in a test.
42///
43/// [`ConnectOptions`] is one: the vendor's models connect with those
44/// options (a base URL, a key, an HTTP client, a protocol family). A
45/// closure from the spec, the catalog and the transport to a handler is
46/// another.
47pub trait Provide: WasmCompatSend + WasmCompatSync + 'static {
48    /// The handler serving `spec`, a model of `catalog`. A handler that
49    /// sends HTTP sends through `transport`: its shared client, or a client
50    /// of its own that it wraps.
51    fn handler(
52        &self,
53        spec: &ModelSpec,
54        catalog: &Catalog,
55        transport: &Transport,
56    ) -> Result<ErasedHandler, ConnectError>;
57}
58
59impl<F> Provide for F
60where
61    F: Fn(&ModelSpec, &Catalog, &Transport) -> Result<ErasedHandler, ConnectError>
62        + WasmCompatSend
63        + WasmCompatSync
64        + 'static,
65{
66    fn handler(
67        &self,
68        spec: &ModelSpec,
69        catalog: &Catalog,
70        transport: &Transport,
71    ) -> Result<ErasedHandler, ConnectError> {
72        self(spec, catalog, transport)
73    }
74}
75
76/// The vendor's models connect as [`Catalog::connect_with`] connects them
77/// with these options, on their HTTP client, else the shared one, wrapped
78/// by the transport.
79impl Provide for ConnectOptions {
80    fn handler(
81        &self,
82        spec: &ModelSpec,
83        catalog: &Catalog,
84        transport: &Transport,
85    ) -> Result<ErasedHandler, ConnectError> {
86        let mut options = self.clone();
87        options.http = match options.http {
88            Some(own) => Some(transport.wrap(own)),
89            None => transport.shared(),
90        };
91        catalog
92            .connect_with(spec, options)
93            .map(|model| adapted(spec, model))
94    }
95}
96
97/// `model`, which serves `spec`, as an effect handler labelled with the
98/// spec's reference.
99fn adapted(spec: &ModelSpec, model: crate::driver::DynModel<Completion>) -> ErasedHandler {
100    ErasedHandler::new(ModelAdapter::<Completion>::new(spec.reference(), model))
101}
102
103/// One wrapper of a [`Transport`], `Send` and `Sync` outside browser wasm.
104#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
105type Wrapper = Arc<dyn Fn(DynHttpClient) -> DynHttpClient + Send + Sync>;
106/// One wrapper of a [`Transport`], which may hold browser values.
107#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
108type Wrapper = Arc<dyn Fn(DynHttpClient) -> DynHttpClient>;
109
110/// The wrappers a [`Connector`] applies to every HTTP client its models are
111/// reached through, in the order added: the first is closest to the
112/// network. Cloning is cheap.
113///
114/// ```
115/// use rig_core::catalog::Transport;
116/// use rig_core::http_client::DynHttpClient;
117///
118/// fn reach(transport: &Transport, own: DynHttpClient) -> DynHttpClient {
119///     // A provider with a client of its own wraps it; one without takes
120///     // the shared client, when the build has one.
121///     transport.wrap(own)
122/// }
123/// ```
124#[derive(Clone, Default)]
125pub struct Transport {
126    wrappers: Vec<Wrapper>,
127}
128
129impl Transport {
130    /// `base` with every wrapper applied.
131    pub fn wrap(&self, base: DynHttpClient) -> DynHttpClient {
132        self.wrappers.iter().fold(base, |client, wrap| wrap(client))
133    }
134
135    /// The shared client, wrapped, when the build has one (the `reqwest`
136    /// feature); `None` otherwise, such as on the web.
137    pub fn shared(&self) -> Option<DynHttpClient> {
138        #[cfg(feature = "reqwest")]
139        {
140            Some(self.wrap(rig_reqwest::shared()))
141        }
142        #[cfg(not(feature = "reqwest"))]
143        {
144            None
145        }
146    }
147}
148
149/// Shows how many wrappers there are.
150impl fmt::Debug for Transport {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        f.debug_struct("Transport")
153            .field("wrappers", &self.wrappers.len())
154            .finish()
155    }
156}
157
158/// Whether a [`Connector`] can reach a catalog model for an agent, and why
159/// not, for a picker's note.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub enum ModelStatus {
162    /// It calls tools and connects.
163    Usable,
164    /// It calls tools, but no key is set: the variables read, in order.
165    NoKey(Vec<Cow<'static, str>>),
166    /// It does not call tools, so an agent cannot use it.
167    NoTools,
168    /// It calls tools but does not connect, for this reason.
169    Unavailable(String),
170}
171
172/// The models an application can use and how each is reached: a catalog,
173/// rig's built-in one by default, whose models connect through the
174/// provider registered for their vendor ([`Self::provide`]), else with the
175/// environment's keys, else through the first [`SignIn`] that serves them,
176/// each on an HTTP client wrapped by its [`Transport`]. Cloning is cheap.
177/// It is `Send` and `Sync` natively; on browser wasm its providers,
178/// sign-ins and wrappers may hold JavaScript values instead.
179#[derive(Clone)]
180pub struct Connector {
181    catalog: Catalog,
182    providers: HashMap<&'static str, Arc<dyn Provide>>,
183    sign_ins: Vec<Arc<dyn SignIn>>,
184    transport: Transport,
185}
186
187impl Default for Connector {
188    fn default() -> Self {
189        Self::new(Catalog::builtin().clone())
190    }
191}
192
193impl Connector {
194    /// A connector for the models of `catalog`, such as the built-in one
195    /// with a user's overrides laid over it.
196    pub fn new(catalog: Catalog) -> Self {
197        Self {
198            catalog,
199            providers: HashMap::new(),
200            sign_ins: Vec::new(),
201            transport: Transport::default(),
202        }
203    }
204
205    /// Wraps the HTTP client every model of this connector is reached
206    /// through, outside the wrappers added before. Connections made before
207    /// the call are not rebuilt.
208    ///
209    /// ```
210    /// use rig_core::catalog::Connector;
211    /// use rig_core::http_client::DynHttpClient;
212    ///
213    /// let mut connector = Connector::default();
214    /// // A wrapper sees every request and response, such as to log them.
215    /// connector.wrap_http(|inner: DynHttpClient| inner);
216    /// ```
217    pub fn wrap_http(
218        &mut self,
219        wrap: impl Fn(DynHttpClient) -> DynHttpClient + WasmCompatSend + WasmCompatSync + 'static,
220    ) {
221        self.transport.wrappers.push(Arc::new(wrap));
222    }
223
224    /// The transport this connector's models are reached through, for a
225    /// host that builds a model of its own beside them.
226    pub fn transport(&self) -> &Transport {
227        &self.transport
228    }
229
230    /// Falls back to `sign_in` for a model the environment has no key for
231    /// and no sign-in added earlier serves.
232    pub fn add_sign_in(&mut self, sign_in: impl SignIn) {
233        self.sign_ins.push(Arc::new(sign_in));
234    }
235
236    /// Reaches every model of `vendor` (any of its protocol families)
237    /// through `provider`, before the environment. A model the provider
238    /// has no key for ([`ConnectError::MissingKey`]) falls back to the
239    /// sign-ins, as one the environment has no key for does. A later
240    /// provider for the same vendor replaces this one: to wrap it instead,
241    /// take it first with [`Self::provider`].
242    ///
243    /// ```
244    /// use rig_core::catalog::{Catalog, Connector, ModelSpec};
245    /// use rig_core::providers::registry::{ConnectOptions, ProviderId};
246    ///
247    /// let corp = ProviderId::declare("corp")?;
248    /// let mut connector = Connector::default();
249    /// connector.catalog_mut().insert(ModelSpec::new(corp, "big").with_tools(true));
250    /// // A built-in vendor at another address.
251    /// let ollama = ProviderId::catalog("ollama").ok_or("known")?;
252    /// connector.provide(ollama, ConnectOptions::new().base_url("http://gpu:11434/v1"));
253    /// assert!(connector.is_provided(ollama) && !connector.is_provided(corp));
254    /// # Ok::<(), Box<dyn std::error::Error>>(())
255    /// ```
256    pub fn provide(&mut self, vendor: ProviderId, provider: impl Provide) {
257        self.providers.insert(vendor.vendor(), Arc::new(provider));
258    }
259
260    /// Whether a provider is registered for `vendor`'s models.
261    pub fn is_provided(&self, vendor: ProviderId) -> bool {
262        self.providers.contains_key(vendor.vendor())
263    }
264
265    /// The provider registered for `vendor`'s models, if any: what a
266    /// provider that wraps it, such as one that changes the key, delegates
267    /// to.
268    pub fn provider(&self, vendor: ProviderId) -> Option<Arc<dyn Provide>> {
269        self.providers.get(vendor.vendor()).cloned()
270    }
271
272    /// The catalog.
273    pub fn catalog(&self) -> &Catalog {
274        &self.catalog
275    }
276
277    /// The catalog, to add or change models in place, keeping the
278    /// providers and sign-ins.
279    pub fn catalog_mut(&mut self) -> &mut Catalog {
280        &mut self.catalog
281    }
282
283    /// The handler of the first sign-in that serves `spec` now, if any.
284    fn signed_in(&self, spec: &ModelSpec) -> Option<Result<ErasedHandler, ConnectError>> {
285        self.sign_ins
286            .iter()
287            .find_map(|sign_in| sign_in.handler(spec, &self.transport))
288    }
289
290    /// The catalog model `reference` names, as [`Catalog::resolve`] finds
291    /// it, and that model as an effect handler: from the provider
292    /// registered for its vendor, else its provider's client built from the
293    /// environment, on the shared HTTP client of the `reqwest` feature, or,
294    /// without a key in either, the handler of a sign-in that serves it. The
295    /// HTTP client is wrapped by [`Self::wrap_http`]'s wrappers.
296    ///
297    /// # Errors
298    ///
299    /// [`ConnectError::NotFound`] for a model the catalog does not list,
300    /// what the registered provider reports, else what
301    /// [`Catalog::connect_with`] reports, when no sign-in serves a model
302    /// the provider or the environment has no key for.
303    pub fn connect(
304        &self,
305        reference: &str,
306    ) -> Result<(Arc<ModelSpec>, ErasedHandler), ConnectError> {
307        let spec = self.catalog.resolve(reference)?.shared();
308        let handler = self.handler(&spec)?;
309        Ok((spec, handler))
310    }
311
312    /// The handler serving `spec`, as [`Self::connect`] finds it.
313    fn handler(&self, spec: &ModelSpec) -> Result<ErasedHandler, ConnectError> {
314        let connected = match self.providers.get(spec.provider.vendor()) {
315            Some(provider) => provider.handler(spec, &self.catalog, &self.transport),
316            None => {
317                let options = match self.transport.shared() {
318                    Some(http) => ConnectOptions::new().http(http),
319                    None => ConnectOptions::new(),
320                };
321                self.catalog
322                    .connect_with(spec, options)
323                    .map(|model| adapted(spec, model))
324            }
325        };
326        match connected {
327            Err(error @ ConnectError::MissingKey { .. }) => {
328                self.signed_in(spec).unwrap_or(Err(error))
329            }
330            other => other,
331        }
332    }
333
334    /// The plan of the first sign-in that would serve `spec`
335    /// ([`SignIn::plan`]).
336    pub fn plan(&self, spec: &ModelSpec) -> Option<&'static str> {
337        self.sign_ins.iter().find_map(|sign_in| sign_in.plan(spec))
338    }
339
340    /// Whether `spec` can serve an agent now, and why not: it must call
341    /// tools and connect as [`Self::connect`] would connect it.
342    ///
343    /// ```
344    /// use rig_core::catalog::{Connector, ModelSpec, ModelStatus};
345    /// use rig_core::providers::registry::ProviderId;
346    ///
347    /// let corp = ProviderId::declare("status-doc-corp")?;
348    /// let connector = Connector::default();
349    /// let chat = ModelSpec::new(corp, "chat");
350    /// assert_eq!(connector.status(&chat), ModelStatus::NoTools);
351    /// let agent = chat.with_tools(true);
352    /// assert!(matches!(connector.status(&agent), ModelStatus::Unavailable(_)));
353    /// # Ok::<(), Box<dyn std::error::Error>>(())
354    /// ```
355    pub fn status(&self, spec: &ModelSpec) -> ModelStatus {
356        if !spec.tools {
357            return ModelStatus::NoTools;
358        }
359        match self.handler(spec) {
360            Ok(_) => ModelStatus::Usable,
361            Err(ConnectError::MissingKey { tried, .. }) => ModelStatus::NoKey(tried),
362            Err(error) => ModelStatus::Unavailable(error.to_string()),
363        }
364    }
365
366    /// Catalog models that call tools and can be reached through a
367    /// registered provider, the environment or a sign-in: first those of
368    /// providers registered, with a key set or signed in, then those of
369    /// providers that need none (local servers such as Ollama), which a
370    /// picker can mark "no key needed". The check builds the handler
371    /// exactly as a request would, once per provider.
372    pub fn reachable(&self) -> Vec<&ModelSpec> {
373        let statuses = self.statuses().into_iter();
374        let usable = statuses.filter(|(_, status)| *status == ModelStatus::Usable);
375        usable.map(|(spec, _)| spec).collect()
376    }
377
378    /// Every catalog model with its [`ModelStatus`]: first the
379    /// [`reachable`](Self::reachable) ones in that order, then the others in
380    /// catalog order. Each provider is checked once.
381    pub fn statuses(&self) -> Vec<(&ModelSpec, ModelStatus)> {
382        let mut checked: HashMap<ProviderId, ModelStatus> = HashMap::new();
383        let mut statuses: Vec<(&ModelSpec, ModelStatus)> = self
384            .catalog
385            .iter()
386            .map(|spec| {
387                let status = match spec.tools {
388                    false => ModelStatus::NoTools,
389                    true => checked
390                        .entry(spec.provider)
391                        .or_insert_with(|| self.status(spec))
392                        .clone(),
393                };
394                (spec, status)
395            })
396            .collect();
397        // Stable: catalog order within each group.
398        statuses.sort_by_key(|(spec, status)| match status {
399            ModelStatus::Usable => {
400                u8::from(!self.is_provided(spec.provider) && !spec.provider.requires_credential())
401            }
402            _ => 2,
403        });
404        statuses
405    }
406}
407
408// On browser wasm a provider, a sign-in and an HTTP wrapper may hold values
409// that are neither `Send` nor `Sync`, such as JavaScript handles.
410#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
411const _: fn(&mut Connector, ProviderId) = |connector, vendor| {
412    use std::rc::Rc;
413
414    struct PageSignIn {
415        _page: Rc<()>,
416    }
417    impl SignIn for PageSignIn {
418        fn handler(
419            &self,
420            _: &ModelSpec,
421            _: &Transport,
422        ) -> Option<Result<ErasedHandler, ConnectError>> {
423            None
424        }
425        fn plan(&self, _: &ModelSpec) -> Option<&'static str> {
426            None
427        }
428    }
429
430    let page = Rc::new(());
431    let wrapped = page.clone();
432    connector.wrap_http(move |client| {
433        let _ = &wrapped;
434        client
435    });
436    let provided = page.clone();
437    connector.provide(vendor, move |_: &ModelSpec, _: &Catalog, _: &Transport| {
438        let _ = &provided;
439        Err(ConnectError::Provider("not on this page".into()))
440    });
441    connector.add_sign_in(PageSignIn { _page: page });
442};
443
444#[cfg(test)]
445mod tests;