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;