Skip to main content

rig_core/providers/
registry.rs

1//! Serializable provider selections, explicit configurations, and
2//! connecting to a model by reference.
3//!
4//! A model reference is `vendor/model`, split at the first `/`
5//! (`anthropic/claude-opus-5-5`, `ollama/qwen3:4b`). The protocol family it
6//! is reached by comes from the catalog row, and [`ConnectOptions`] can name
7//! another. [`Catalog::connect`] builds a model from a reference with the key
8//! from the environment; a [`ProviderConfig`] is the explicit, serializable
9//! configuration, which discards its credential when written.
10//!
11//! ```no_run
12//! use rig_core::catalog::Catalog;
13//! let model = Catalog::builtin().connect("deepseek/deepseek-chat")?; // DEEPSEEK_API_KEY
14//! # Ok::<(), Box<dyn std::error::Error>>(())
15//! ```
16
17use std::borrow::Cow;
18use std::fmt;
19use std::hash::{Hash, Hasher};
20
21use serde::de;
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24use crate::catalog::{Catalog, ModelFacts, ModelSpec, NotFound, Reference};
25use crate::client::env::{self, EnvError};
26use crate::driver::DynModel;
27use crate::http_client::{DynHttpClient, HttpClientExt};
28use crate::operation::Completion;
29use crate::providers::{anthropic, gemini, openai};
30use crate::serve::ErasedHandler;
31use crate::serve::adapters::ModelAdapter;
32use crate::wire::Secret;
33
34/// Every dialect this build knows, for
35/// [`openai::wire::Dialect`]'s [`Deserialize`](serde::Deserialize) lookup.
36///
37/// Keyed by [`openai::wire::Dialect::name`], so the regional and endpoint variants that
38/// share a provider name are not listed: a stored wire keeps its base URL,
39/// which is what distinguishes them.
40pub(crate) const OPENAI_DIALECTS: &[&openai::wire::Dialect] = &[
41    &openai::wire::OPENAI,
42    &openai::wire::AZURE,
43    &openai::wire::DEEPSEEK,
44    &openai::wire::GROQ,
45    &openai::wire::HYPERBOLIC,
46    &openai::wire::MIRA,
47    &openai::wire::PERPLEXITY,
48    &openai::wire::TOGETHER,
49    &openai::wire::HUGGINGFACE,
50    &openai::wire::LLAMACPP,
51    &openai::wire::MISTRAL,
52    &openai::wire::OPENROUTER,
53    &openai::wire::VENICE,
54    &openai::wire::DOUBLEWORD,
55    &openai::wire::ZAI,
56    &openai::wire::MINIMAX,
57    &openai::wire::MOONSHOT,
58    &openai::wire::XIAOMIMIMO,
59    &openai::wire::COHERE,
60    &openai::wire::OLLAMA,
61    &crate::providers::xai::DIALECT,
62    &crate::providers::chatgpt::DIALECT,
63    &crate::providers::copilot::wire::DIALECT,
64];
65
66/// A provider's request grammar and configuration type.
67/// The OpenAI family includes Chat Completions and Responses; select the endpoint
68/// through [`openai::OpenAIConfig::with_route`].
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
70pub enum Format {
71    /// OpenAI's grammar: Chat Completions and the Responses endpoint.
72    OpenAi,
73    /// Anthropic's Messages grammar.
74    Anthropic,
75    /// Gemini's GenerateContent grammar.
76    Gemini,
77}
78
79impl Format {
80    /// Every protocol family, in registration order.
81    pub const ALL: [Format; 3] = [Format::OpenAi, Format::Anthropic, Format::Gemini];
82
83    /// The family's stable serialized name.
84    pub fn as_str(self) -> &'static str {
85        match self {
86            Format::OpenAi => "openai",
87            Format::Anthropic => "anthropic",
88            Format::Gemini => "gemini",
89        }
90    }
91
92    /// The family `name` spells, or `None`.
93    pub fn named(name: &str) -> Option<Self> {
94        Self::ALL.into_iter().find(|format| format.as_str() == name)
95    }
96
97    /// Every family's name, for an error message.
98    fn names() -> String {
99        Self::ALL
100            .iter()
101            .map(|format| format.as_str())
102            .collect::<Vec<_>>()
103            .join(", ")
104    }
105}
106
107impl fmt::Display for Format {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(self.as_str())
110    }
111}
112
113impl Serialize for Format {
114    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
115        serializer.serialize_str(self.as_str())
116    }
117}
118
119impl<'de> Deserialize<'de> for Format {
120    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
121        let name = String::deserialize(deserializer)?;
122        Self::named(&name).ok_or_else(|| {
123            de::Error::custom(format!(
124                "`{name}` is not a protocol family ({})",
125                Format::names()
126            ))
127        })
128    }
129}
130
131/// Registered dialect definition and its configuration family.
132#[derive(Debug, Clone, Copy)]
133enum Registered {
134    OpenAi(openai::wire::Dialect),
135    Anthropic(anthropic::wire::Dialect),
136    Gemini,
137}
138
139/// Identity is the `(vendor, format)` pair, as for [`ProviderId`].
140impl PartialEq for Registered {
141    fn eq(&self, other: &Self) -> bool {
142        self.vendor() == other.vendor() && self.format() == other.format()
143    }
144}
145
146impl Registered {
147    fn vendor(&self) -> &'static str {
148        match self {
149            Self::OpenAi(dialect) => dialect.name,
150            Self::Anthropic(dialect) => dialect.name,
151            Self::Gemini => gemini::PROVIDER_NAME,
152        }
153    }
154
155    fn format(&self) -> Format {
156        match self {
157            Self::OpenAi(_) => Format::OpenAi,
158            Self::Anthropic(_) => Format::Anthropic,
159            Self::Gemini => Format::Gemini,
160        }
161    }
162
163    fn config(&self, api_key: impl Into<Secret>) -> ProviderConfig {
164        match self {
165            Self::OpenAi(dialect) => {
166                ProviderConfig::OpenAi(openai::wire::OpenAIConfig::with_key(dialect, api_key))
167            }
168            Self::Anthropic(dialect) => ProviderConfig::Anthropic(
169                anthropic::wire::AnthropicConfig::with_key(dialect, api_key),
170            ),
171            Self::Gemini => ProviderConfig::Gemini(gemini::GeminiConfig::new(api_key)),
172        }
173    }
174
175    /// This selection's preset, configured from the environment variables
176    /// its dialect names: the key from the first of
177    /// [`key_envs`](Self::key_envs) set to a non-empty value, and the base
178    /// URL and other settings the dialect reads.
179    fn config_from_env(&self) -> Result<ProviderConfig, ConnectError> {
180        let tried = self.key_envs();
181        let (api_key, name) = key_from_env(self.vendor(), tried, self.requires_credential())?;
182        Ok(match self {
183            Self::OpenAi(dialect) => {
184                let auth = openai_auth(dialect, name);
185                ProviderConfig::OpenAi(openai::wire::OpenAIConfig::from_env_with_credential(
186                    dialect, api_key, auth,
187                )?)
188            }
189            Self::Anthropic(dialect) => ProviderConfig::Anthropic(
190                anthropic::wire::AnthropicConfig::from_env_with_credential(dialect, api_key)?,
191            ),
192            Self::Gemini => ProviderConfig::Gemini(gemini::GeminiConfig::new(api_key)),
193        })
194    }
195
196    /// The variables this selection reads its key from, in order.
197    fn key_envs(&self) -> Vec<&'static str> {
198        match self {
199            Self::OpenAi(dialect) => openai_key_envs(dialect),
200            Self::Anthropic(dialect) => vec![dialect.api_key_env],
201            Self::Gemini => vec![gemini::API_KEY_ENV],
202        }
203    }
204
205    /// Whether this selection needs a credential at all.
206    fn requires_credential(&self) -> bool {
207        match self {
208            Self::OpenAi(dialect) => openai_requires_credential(dialect),
209            Self::Anthropic(_) | Self::Gemini => true,
210        }
211    }
212}
213
214/// The variables an OpenAI-family `dialect` reads its key from: its own,
215/// then its alternative's.
216fn openai_key_envs(dialect: &openai::wire::Dialect) -> Vec<&'static str> {
217    std::iter::once(dialect.api_key_env)
218        .chain(
219            dialect
220                .alternate_auth
221                .map(|alternative| alternative.api_key_env),
222        )
223        .collect()
224}
225
226/// Whether an OpenAI-family `dialect` needs a credential: a local
227/// `llama-server` or Ollama daemon authenticates optionally.
228fn openai_requires_credential(dialect: &openai::wire::Dialect) -> bool {
229    !matches!(dialect.quirks.auth, openai::wire::Auth::OptionalBearer)
230}
231
232/// How an OpenAI-family `dialect` sends a key read from the variable
233/// `name`: its alternative's way for the alternative's variable, its own
234/// otherwise.
235fn openai_auth(dialect: &openai::wire::Dialect, name: Option<&'static str>) -> openai::wire::Auth {
236    match dialect.alternate_auth {
237        Some(alternative) if name == Some(alternative.api_key_env) => alternative.auth,
238        _ => dialect.quirks.auth,
239    }
240}
241
242/// The key the first of `tried` holds, and its name. An empty value counts
243/// as unset. When none is set, a vendor that needs no credential gets an
244/// empty key, and any other is [`ConnectError::MissingKey`].
245fn key_from_env(
246    vendor: &str,
247    tried: Vec<&'static str>,
248    required: bool,
249) -> Result<(String, Option<&'static str>), ConnectError> {
250    for name in &tried {
251        if let Some(value) = env::optional(name)?.filter(|value| !value.is_empty()) {
252            return Ok((value, Some(*name)));
253        }
254    }
255    if required {
256        return Err(ConnectError::MissingKey {
257            vendor: vendor.to_owned(),
258            tried: tried.into_iter().map(Cow::Borrowed).collect(),
259        });
260    }
261    Ok((String::new(), None))
262}
263
264/// A provider the model catalog files models under that the registry cannot
265/// configure: an SDK-backed or local provider in a companion crate, or one
266/// that serves no completions.
267#[derive(Debug)]
268struct CatalogOnly {
269    /// The provider's own descriptor name.
270    vendor: &'static str,
271    /// Where its models are served from, for the error `connect` returns.
272    home: &'static str,
273    /// Whether it needs a credential.
274    credential: bool,
275}
276
277/// Every catalog-only provider.
278static CATALOG_ONLY: [CatalogOnly; 5] = [
279    CatalogOnly {
280        vendor: "aws_bedrock",
281        home: "the `rig-bedrock` crate",
282        credential: true,
283    },
284    CatalogOnly {
285        vendor: "vertexai",
286        home: "the `rig-vertexai` crate",
287        credential: true,
288    },
289    CatalogOnly {
290        vendor: "gemini-grpc",
291        home: "the `rig-gemini-grpc` crate",
292        credential: true,
293    },
294    CatalogOnly {
295        vendor: "candle",
296        home: "the `rig-candle` crate",
297        credential: false,
298    },
299    CatalogOnly {
300        vendor: "voyageai",
301        home: "`rig_core::providers::voyageai`, which serves embeddings and reranking only",
302        credential: true,
303    },
304];
305
306/// What a [`ProviderId`] names.
307#[derive(Debug, Clone, Copy)]
308enum Kind {
309    /// A selection the registry configures.
310    Registered(Registered),
311    /// A provider only the model catalog names.
312    CatalogOnly(&'static CatalogOnly),
313    /// A vendor declared at run time ([`ProviderId::declare`]).
314    Declared(&'static str),
315}
316
317/// The vendors declared at run time, each name stored once for the life of
318/// the process, so a declared [`ProviderId`] stays `Copy` and names its
319/// vendor as `&'static str` like a built-in one.
320static DECLARED: std::sync::RwLock<Vec<&'static str>> = std::sync::RwLock::new(Vec::new());
321
322/// The declared vendor named `vendor`, if any.
323fn declared(vendor: &str) -> Option<&'static str> {
324    DECLARED
325        .read()
326        .unwrap_or_else(std::sync::PoisonError::into_inner)
327        .iter()
328        .copied()
329        .find(|name| *name == vendor)
330}
331
332/// Validated vendor and protocol-family pair from this build's dialect tables,
333/// a catalog-only provider the registry cannot configure, or a vendor
334/// declared at run time ([`Self::declare`]). Construction and
335/// deserialization reject unregistered pairs; only [`ProviderId::catalog`],
336/// [`ProviderId::declare`] and the model catalog produce a catalog-only or
337/// declared id, which has no format and no preset. Equality and hashing use
338/// the pair, not dialect options; display emits `vendor/format`, or the
339/// vendor alone for a catalog-only or declared id.
340#[derive(Debug, Clone, Copy)]
341pub struct ProviderId(Kind);
342
343/// Identity is the `(vendor, format)` pair, never the dialect's payload: two
344/// ids that name the same selection are the same id.
345impl PartialEq for ProviderId {
346    fn eq(&self, other: &Self) -> bool {
347        self.vendor() == other.vendor() && self.format() == other.format()
348    }
349}
350
351impl Eq for ProviderId {}
352
353impl Hash for ProviderId {
354    fn hash<H: Hasher>(&self, state: &mut H) {
355        self.vendor().hash(state);
356        self.format().hash(state);
357    }
358}
359
360impl ProviderId {
361    /// Every selection this build registers: one per OpenAI-shaped dialect,
362    /// one per Messages-format dialect, and Gemini. Catalog-only providers
363    /// are not selections.
364    pub fn all() -> impl Iterator<Item = ProviderId> {
365        openai::wire::all()
366            .map(|dialect| Registered::OpenAi(*dialect))
367            .chain(anthropic::wire::all().map(|dialect| Registered::Anthropic(*dialect)))
368            .chain(std::iter::once(Registered::Gemini))
369            .map(|registered| ProviderId(Kind::Registered(registered)))
370    }
371
372    /// The selection `vendor` names in `format`, or `None` when this build
373    /// registers no such pair.
374    pub fn new(vendor: &str, format: Format) -> Option<Self> {
375        let registered = match format {
376            Format::OpenAi => {
377                openai::wire::by_name(vendor).map(|dialect| Registered::OpenAi(*dialect))
378            }
379            Format::Anthropic => {
380                anthropic::wire::Dialect::by_name(vendor).map(Registered::Anthropic)
381            }
382            Format::Gemini => (vendor == gemini::PROVIDER_NAME).then_some(Registered::Gemini),
383        };
384        registered.map(|registered| Self(Kind::Registered(registered)))
385    }
386
387    /// The id the model catalog files `vendor`'s models under: its
388    /// primary selection, or a catalog-only id for a provider the registry
389    /// cannot configure (`aws_bedrock`, `vertexai`, `gemini-grpc`, `candle`,
390    /// `voyageai`). `None` for a vendor this build does not know.
391    ///
392    /// A vendor registered in one family has that family as its primary.
393    /// The vendors registered in two declare theirs: Anthropic's for
394    /// `minimax`, OpenAI's for `zai`, `moonshot` and `xiaomimimo`. A model
395    /// reference that names no family is reached by its catalog row's,
396    /// which is the primary unless the row says otherwise.
397    pub fn catalog(vendor: &str) -> Option<Self> {
398        let primary = PRIMARY
399            .iter()
400            .find(|(name, _)| *name == vendor)
401            .and_then(|(_, format)| Self::new(vendor, *format));
402        primary
403            .or_else(|| Self::vendor_selections(vendor).next())
404            .or_else(|| {
405                CATALOG_ONLY
406                    .iter()
407                    .find(|provider| provider.vendor == vendor)
408                    .map(|provider| Self(Kind::CatalogOnly(provider)))
409            })
410            .or_else(|| declared(vendor).map(|name| Self(Kind::Declared(name))))
411    }
412
413    /// Declare `vendor`, a provider this build has no preset for, such as a
414    /// company gateway (`corp`) or a local server under a name of its own,
415    /// and return the id the catalog files its models under. From then on
416    /// [`Self::catalog`] finds it, so `corp/big` resolves, parses and reads
417    /// back like a built-in reference once the catalog lists the model
418    /// ([`Catalog::insert`]). The registry cannot configure it: whoever
419    /// declares it says how its models are reached, such as with
420    /// [`Connector::provide`](crate::catalog::Connector::provide) or by
421    /// building a [`ProviderConfig`] from a dialect of its own.
422    ///
423    /// Declaring is idempotent and process-wide. A vendor this build already
424    /// knows comes back as [`Self::catalog`] gives it.
425    ///
426    /// ```
427    /// use rig_core::catalog::{Catalog, ModelSpec};
428    /// use rig_core::providers::registry::ProviderId;
429    ///
430    /// let corp = ProviderId::declare("corp")?;
431    /// let mut catalog = Catalog::builtin().clone();
432    /// catalog.insert(ModelSpec::new(corp, "big").with_tools(true));
433    /// assert_eq!(catalog.resolve("corp/big").map_err(|e| e.to_string())?.spec.provider, corp);
434    /// assert_eq!(ProviderId::declare("anthropic")?, ProviderId::catalog("anthropic").ok_or("known")?);
435    /// # Ok::<(), Box<dyn std::error::Error>>(())
436    /// ```
437    ///
438    /// # Errors
439    ///
440    /// [`SelectionError::Malformed`] for an empty name, or one with a `/`
441    /// or whitespace, which a `vendor/model` reference could not spell.
442    pub fn declare(vendor: &str) -> Result<Self, SelectionError> {
443        if vendor.is_empty() || vendor.contains('/') || vendor.contains(char::is_whitespace) {
444            return Err(SelectionError::Malformed {
445                selection: vendor.to_owned(),
446            });
447        }
448        if let Some(known) = Self::catalog(vendor) {
449            return Ok(known);
450        }
451        let mut names = DECLARED
452            .write()
453            .unwrap_or_else(std::sync::PoisonError::into_inner);
454        let name = match names.iter().copied().find(|name| *name == vendor) {
455            Some(name) => name,
456            None => {
457                let name: &'static str = Box::leak(vendor.to_owned().into_boxed_str());
458                names.push(name);
459                name
460            }
461        };
462        Ok(Self(Kind::Declared(name)))
463    }
464
465    /// Every vendor name [`Self::catalog`] knows, registered, catalog-only
466    /// or declared. A vendor registered in two families appears twice.
467    pub(crate) fn catalog_vendors() -> impl Iterator<Item = &'static str> {
468        let declared: Vec<&'static str> = DECLARED
469            .read()
470            .unwrap_or_else(std::sync::PoisonError::into_inner)
471            .clone();
472        Self::all()
473            .map(|id| id.vendor())
474            .chain(CATALOG_ONLY.iter().map(|provider| provider.vendor))
475            .chain(declared)
476    }
477
478    /// The vendor, spelled as the provider's own descriptor name.
479    pub fn vendor(&self) -> &'static str {
480        match &self.0 {
481            Kind::Registered(registered) => registered.vendor(),
482            Kind::CatalogOnly(provider) => provider.vendor,
483            Kind::Declared(vendor) => vendor,
484        }
485    }
486
487    /// The protocol family, or `None` for a catalog-only or declared
488    /// provider.
489    pub fn format(&self) -> Option<Format> {
490        match &self.0 {
491            Kind::Registered(registered) => Some(registered.format()),
492            Kind::CatalogOnly(_) | Kind::Declared(_) => None,
493        }
494    }
495
496    /// Whether the registry can configure this provider. A catalog-only
497    /// provider is served by its companion crate instead, and a declared one
498    /// by whoever declared it.
499    pub fn is_registered(&self) -> bool {
500        matches!(self.0, Kind::Registered(_))
501    }
502
503    /// Whether this is a vendor declared at run time ([`Self::declare`]).
504    pub fn is_declared(&self) -> bool {
505        matches!(self.0, Kind::Declared(_))
506    }
507
508    /// Every selection registered for `vendor`, in registration order.
509    pub fn vendor_selections(vendor: &str) -> impl Iterator<Item = ProviderId> + '_ {
510        Self::all().filter(move |id| id.vendor() == vendor)
511    }
512
513    /// Resolve `vendor` or `vendor/format`.
514    ///
515    /// A bare vendor is accepted only when this build registers exactly one
516    /// family for it; otherwise the error names the qualified alternatives,
517    /// each of which this resolver accepts. A catalog-only provider is not a
518    /// selection, so it does not resolve.
519    pub fn resolve(selection: &str) -> Result<Self, SelectionError> {
520        let malformed = || SelectionError::Malformed {
521            selection: selection.to_owned(),
522        };
523        let (vendor, format) = match selection.split_once('/') {
524            Some((_, rest)) if rest.contains('/') => return Err(malformed()),
525            Some((vendor, format)) => (vendor, Some(format)),
526            None => (selection, None),
527        };
528        if vendor.is_empty() {
529            return Err(malformed());
530        }
531        let Some(format) = format else {
532            let mut registered = Self::vendor_selections(vendor);
533            let first = registered.next().ok_or_else(|| SelectionError::Unknown {
534                vendor: vendor.to_owned(),
535            })?;
536            return match registered.next() {
537                None => Ok(first),
538                Some(_) => Err(SelectionError::Ambiguous {
539                    vendor: vendor.to_owned(),
540                    alternatives: alternatives(vendor),
541                }),
542            };
543        };
544        let Some(family) = Format::named(format) else {
545            return Err(SelectionError::UnknownFormat {
546                format: format.to_owned(),
547            });
548        };
549        Self::selection(vendor, family)
550    }
551
552    /// Build this selection's preset with `api_key`, or `None` for a
553    /// catalog-only or declared provider. Copilot requires an exchanged
554    /// session token, not a GitHub OAuth token.
555    pub fn config(&self, api_key: impl Into<Secret>) -> Option<ProviderConfig> {
556        match &self.0 {
557            Kind::Registered(registered) => Some(registered.config(api_key)),
558            Kind::CatalogOnly(_) | Kind::Declared(_) => None,
559        }
560    }
561
562    /// The environment variables tried for the key, in order: the
563    /// dialect's own, then its alternative credential's (Azure's
564    /// `AZURE_TOKEN`). An empty value counts as unset. Empty for a
565    /// catalog-only provider, whose companion crate reads its own
566    /// credentials, and for a declared one.
567    pub fn api_key_envs(&self) -> Vec<&'static str> {
568        match &self.0 {
569            Kind::Registered(registered) => registered.key_envs(),
570            Kind::CatalogOnly(_) | Kind::Declared(_) => Vec::new(),
571        }
572    }
573
574    /// Whether this selection needs a credential at all. `false` for a
575    /// declared provider: whoever reaches it holds what it needs.
576    ///
577    /// A local `llama-server` authenticates optionally, so naming its
578    /// variable is a hint rather than a requirement.
579    pub fn requires_credential(&self) -> bool {
580        match &self.0 {
581            Kind::Registered(registered) => registered.requires_credential(),
582            Kind::CatalogOnly(provider) => provider.credential,
583            Kind::Declared(_) => false,
584        }
585    }
586
587    /// The registered selection, or [`ConnectError::CatalogOnly`].
588    fn registered(self) -> Result<Registered, ConnectError> {
589        match self.0 {
590            Kind::Registered(registered) => Ok(registered),
591            Kind::CatalogOnly(provider) => Err(ConnectError::CatalogOnly {
592                vendor: provider.vendor.to_owned(),
593                served_by: provider.home,
594            }),
595            Kind::Declared(vendor) => Err(ConnectError::CatalogOnly {
596                vendor: vendor.to_owned(),
597                served_by: "whoever declared it: provide it with `Connector::provide`",
598            }),
599        }
600    }
601
602    /// `vendor` in `format`, or why this build registers no such pair.
603    fn selection(vendor: &str, format: Format) -> Result<Self, SelectionError> {
604        Self::new(vendor, format).ok_or_else(|| {
605            let alternatives = alternatives(vendor);
606            match alternatives.is_empty() {
607                true => SelectionError::Unknown {
608                    vendor: vendor.to_owned(),
609                },
610                false => SelectionError::Unregistered {
611                    vendor: vendor.to_owned(),
612                    format,
613                    alternatives,
614                },
615            }
616        })
617    }
618}
619
620/// The primary family of each vendor registered in more than one, as
621/// models.dev lists the vendor's own API. [`ProviderId::catalog`] reads it.
622const PRIMARY: [(&str, Format); 4] = [
623    ("zai", Format::OpenAi),
624    ("minimax", Format::Anthropic),
625    ("moonshot", Format::OpenAi),
626    ("xiaomimimo", Format::OpenAi),
627];
628
629/// The qualified spellings registered for `vendor`, in registration order.
630fn alternatives(vendor: &str) -> Vec<String> {
631    ProviderId::vendor_selections(vendor)
632        .map(|id| id.to_string())
633        .collect()
634}
635
636impl fmt::Display for ProviderId {
637    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
638        match self.format() {
639            Some(format) => write!(f, "{}/{format}", self.vendor()),
640            None => f.write_str(self.vendor()),
641        }
642    }
643}
644
645impl Serialize for ProviderId {
646    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
647        serializer.collect_str(self)
648    }
649}
650
651/// Reads a selection as [`ProviderId::resolve`] does, and a catalog-only or
652/// declared provider by its vendor name, so a catalog id reads back. A
653/// declared vendor reads back only once this process declared it.
654impl<'de> Deserialize<'de> for ProviderId {
655    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
656        let selection = String::deserialize(deserializer)?;
657        Self::resolve(&selection)
658            .or_else(|error| {
659                Self::catalog(&selection)
660                    .filter(|id| !id.is_registered())
661                    .ok_or(error)
662            })
663            .map_err(de::Error::custom)
664    }
665}
666
667/// Why a provider selection did not resolve.
668#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
669pub enum SelectionError {
670    /// No registered provider goes by this vendor name.
671    #[error("no registered provider is named `{vendor}`")]
672    Unknown {
673        /// The vendor named.
674        vendor: String,
675    },
676    /// The vendor is registered, but not for this protocol family.
677    #[error(
678        "`{vendor}` speaks no {format} endpoint in this build (it speaks {})",
679        alternatives.join(", ")
680    )]
681    Unregistered {
682        /// The vendor named.
683        vendor: String,
684        /// The family named.
685        format: Format,
686        /// The vendor's registered selections, canonically spelled.
687        alternatives: Vec<String>,
688    },
689    /// A bare vendor with more than one registered family.
690    #[error(
691        "`{vendor}` names more than one registered selection: name one of {}",
692        alternatives.join(", ")
693    )]
694    Ambiguous {
695        /// The vendor named.
696        vendor: String,
697        /// The vendor's registered selections, canonically spelled.
698        alternatives: Vec<String>,
699    },
700    /// The family is not one this crate knows.
701    #[error("`{format}` is not a protocol family ({})", Format::names())]
702    UnknownFormat {
703        /// The family named.
704        format: String,
705    },
706    /// Not `vendor` or `vendor/format` at all.
707    #[error("`{selection}` is not a provider selection: expected `vendor` or `vendor/format`")]
708    Malformed {
709        /// What was given.
710        selection: String,
711    },
712}
713
714/// A provider configuration tagged by protocol family, such as `{"openai": {…}}`.
715/// Serialization rejects unregistered or modified dialect definitions. Set
716/// persistent hosts and typed options on the configuration, not the dialect.
717/// Deserialization reports unknown configuration fields by name.
718#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
719pub enum ProviderConfig {
720    /// An OpenAI-shaped provider, on either of its two endpoints.
721    #[serde(rename = "openai")]
722    OpenAi(openai::wire::OpenAIConfig),
723    /// A Messages-format provider.
724    #[serde(rename = "anthropic")]
725    Anthropic(anthropic::wire::AnthropicConfig),
726    /// Gemini.
727    #[serde(rename = "gemini")]
728    Gemini(gemini::GeminiConfig),
729}
730
731impl ProviderConfig {
732    /// The catalog selection with this dialect's name, if registered.
733    /// Host and option overrides do not change the selection; its preset
734    /// remains the catalog's, never a custom dialect's payload.
735    pub fn id(&self) -> Option<ProviderId> {
736        ProviderId::new(self.vendor(), self.format())
737    }
738
739    /// The configured dialect's name, including unregistered dialects.
740    pub fn vendor(&self) -> &'static str {
741        match self {
742            Self::OpenAi(provider) => provider.dialect.name,
743            Self::Anthropic(provider) => provider.dialect.name,
744            Self::Gemini(_) => gemini::PROVIDER_NAME,
745        }
746    }
747
748    /// The configuration family, independently of catalog membership.
749    pub fn format(&self) -> Format {
750        match self {
751            Self::OpenAi(_) => Format::OpenAi,
752            Self::Anthropic(_) => Format::Anthropic,
753            Self::Gemini(_) => Format::Gemini,
754        }
755    }
756
757    /// Whether the credential is empty, as it is after serialization round-trip
758    /// because [`Secret`] omits its value.
759    pub fn is_unauthenticated(&self) -> bool {
760        match self {
761            Self::OpenAi(provider) => provider.api_key.is_empty(),
762            Self::Anthropic(provider) => provider.api_key.is_empty(),
763            Self::Gemini(provider) => provider.api_key.is_empty(),
764        }
765    }
766
767    /// The same configuration with `api_key` as its credential: how a host
768    /// rehydrates a configuration it loaded from data.
769    pub fn with_credential(mut self, api_key: impl Into<Secret>) -> Self {
770        let api_key = api_key.into();
771        match &mut self {
772            Self::OpenAi(provider) => provider.api_key = api_key,
773            Self::Anthropic(provider) => provider.api_key = api_key,
774            Self::Gemini(provider) => provider.api_key = api_key,
775        }
776        self
777    }
778
779    /// The base URL this configuration sends requests to. Endpoint paths,
780    /// such as `/chat/completions` or `/v1/messages`, resolve against it.
781    /// A route the dialect serves at the server root drops a trailing `/v1`
782    /// from it first.
783    ///
784    /// A Copilot preset built with a session token holds the endpoint that
785    /// token names. [`with_credential`](Self::with_credential) keeps the URL
786    /// it finds.
787    pub fn base_url(&self) -> &str {
788        match self {
789            Self::OpenAi(provider) => &provider.base_url,
790            Self::Anthropic(provider) => &provider.base_url,
791            Self::Gemini(provider) => &provider.base_url,
792        }
793    }
794
795    /// The same configuration sending to `base_url`, such as a proxy, with
796    /// every other setting kept. A Messages-format configuration normalizes
797    /// the URL with [`anthropic::wire::normalize_base_url`].
798    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
799        let base_url = base_url.into();
800        match self {
801            Self::OpenAi(provider) => Self::OpenAi(provider.with_base_url(base_url)),
802            Self::Anthropic(provider) => Self::Anthropic(provider.with_base_url(base_url)),
803            Self::Gemini(provider) => Self::Gemini(provider.with_base_url(base_url)),
804        }
805    }
806
807    /// The completion wire for `model`, bound to `http` and encoding and
808    /// pricing with `facts`, erased behind a [`ModelAdapter`] labelled
809    /// `label`: [`Self::completion_model`] as an effect handler.
810    pub fn completion_handler(
811        &self,
812        label: &str,
813        model: &str,
814        http: DynHttpClient,
815        facts: ModelFacts,
816    ) -> ErasedHandler {
817        ErasedHandler::new(ModelAdapter::new(
818            label,
819            self.completion_model(model, http, facts),
820        ))
821    }
822
823    /// The provider's completion model for `model` on `http`, encoding with
824    /// `facts` and pricing its replies by them, erased: how a model the
825    /// catalog lists under a declared vendor ([`ProviderId::declare`]) is
826    /// reached on a gateway of its own, with its catalog prices. Pass
827    /// [`ModelFacts::new`] of the model's spec, or
828    /// [`Catalog::facts`](crate::catalog::Catalog::facts).
829    ///
830    /// The model uses the provider's own completion wire, including
831    /// Copilot's model-dependent routing and request envelope. Explicit
832    /// hosts, routes and typed options are preserved.
833    pub fn completion_model(
834        &self,
835        model: &str,
836        http: DynHttpClient,
837        facts: ModelFacts,
838    ) -> DynModel<Completion> {
839        match self {
840            Self::OpenAi(provider) => {
841                let mut model = provider.clone().connect(http).completion(model);
842                model.wire = model.wire.with_facts(facts);
843                model.erase()
844            }
845            Self::Anthropic(provider) => {
846                let mut model = provider.clone().connect(http).completion(model);
847                model.wire = model.wire.with_facts(facts);
848                model.erase()
849            }
850            Self::Gemini(provider) => {
851                let mut model = provider.clone().connect(http).completion(model);
852                model.wire = model.wire.with_facts(facts);
853                model.erase()
854            }
855        }
856    }
857}
858
859/// What [`Catalog::connect`] connects to: a catalog entry, or a
860/// `vendor/model` reference, as [`Catalog::resolve`] reads one.
861#[non_exhaustive]
862#[derive(Clone, Copy, Debug)]
863pub enum ModelSelector<'a> {
864    /// A catalog entry: its provider, family and id.
865    Spec(&'a ModelSpec),
866    /// `vendor/model`, or the older `vendor/format:model`.
867    Reference(&'a str),
868}
869
870impl<'a> From<&'a ModelSpec> for ModelSelector<'a> {
871    fn from(spec: &'a ModelSpec) -> Self {
872        Self::Spec(spec)
873    }
874}
875
876impl<'a> From<&'a str> for ModelSelector<'a> {
877    fn from(reference: &'a str) -> Self {
878        Self::Reference(reference)
879    }
880}
881
882impl<'a> From<&'a String> for ModelSelector<'a> {
883    fn from(reference: &'a String) -> Self {
884        Self::Reference(reference)
885    }
886}
887
888/// How [`Catalog::connect_with`] connects: an explicit key and the account
889/// it belongs to, an HTTP client, a protocol family and a base URL. Each one
890/// left unset takes its default: the key from the environment, the shared
891/// reqwest client, the family the catalog row is reached by, and the
892/// dialect's base URL.
893///
894/// ```no_run
895/// use rig_core::catalog::Catalog;
896/// use rig_core::providers::registry::{ConnectOptions, Format};
897///
898/// let options = ConnectOptions::new().format(Format::Anthropic);
899/// let model = Catalog::builtin().connect_with("zai/glm-5", options)?;
900/// # Ok::<(), Box<dyn std::error::Error>>(())
901/// ```
902#[non_exhaustive]
903#[derive(Clone, Default)]
904pub struct ConnectOptions {
905    api_key: Option<Secret>,
906    account_id: Option<String>,
907    pub(crate) http: Option<DynHttpClient>,
908    format: Option<Format>,
909    base_url: Option<String>,
910}
911
912impl ConnectOptions {
913    /// No option set.
914    pub fn new() -> Self {
915        Self::default()
916    }
917
918    /// Credential with `api_key` instead of reading the environment. Only
919    /// the key is given: no other variable is read either.
920    pub fn api_key(mut self, api_key: impl Into<Secret>) -> Self {
921        self.api_key = Some(api_key.into());
922        self
923    }
924
925    /// Name `account_id` as the account the key belongs to, as a ChatGPT
926    /// subscription credential needs (the `ChatGPT-Account-Id` header).
927    /// Only the OpenAI family sends it; the others ignore it.
928    ///
929    /// ```no_run
930    /// use rig_core::catalog::Catalog;
931    /// use rig_core::providers::registry::ConnectOptions;
932    ///
933    /// let options = ConnectOptions::new().api_key("access-token").account_id("account");
934    /// let model = Catalog::builtin().connect_with("chatgpt/gpt-5.5", options)?;
935    /// # Ok::<(), Box<dyn std::error::Error>>(())
936    /// ```
937    pub fn account_id(mut self, account_id: impl Into<String>) -> Self {
938        self.account_id = Some(account_id.into());
939        self
940    }
941
942    /// Send through `http` instead of the shared reqwest client.
943    pub fn http(mut self, http: impl HttpClientExt + 'static) -> Self {
944        self.http = Some(DynHttpClient::new(http));
945        self
946    }
947
948    /// Speak `format` instead of the family the catalog row is reached by.
949    /// The vendor must be registered for it.
950    pub fn format(mut self, format: Format) -> Self {
951        self.format = Some(format);
952        self
953    }
954
955    /// Send to `base_url`, such as a proxy, instead of the dialect's.
956    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
957        self.base_url = Some(base_url.into());
958        self
959    }
960}
961
962/// Never shows the key.
963impl fmt::Debug for ConnectOptions {
964    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
965        f.debug_struct("ConnectOptions")
966            .field("api_key", &self.api_key.as_ref().map(|_| "<set>"))
967            .field("account_id", &self.account_id.as_ref().map(|_| "<set>"))
968            .field("http", &self.http.as_ref().map(|_| "<set>"))
969            .field("format", &self.format)
970            .field("base_url", &self.base_url)
971            .finish()
972    }
973}
974
975/// Why [`Catalog::connect`] built no model.
976#[non_exhaustive]
977#[derive(Debug, thiserror::Error)]
978pub enum ConnectError {
979    /// The reference is not `vendor/model`.
980    #[error("`{reference}` names no model: expected `vendor/model`")]
981    Malformed {
982        /// What was given.
983        reference: String,
984    },
985    /// The provider is one only the catalog knows; its companion crate
986    /// serves its models.
987    #[error("the registry cannot connect to `{vendor}`: its models are served by {served_by}")]
988    CatalogOnly {
989        /// The provider's vendor name.
990        vendor: String,
991        /// Where its models are served from.
992        served_by: &'static str,
993    },
994    /// The reference names a vendor this build does not know. Its
995    /// suggestions are close references the catalog lists.
996    #[error(transparent)]
997    NotFound(#[from] NotFound),
998    /// The vendor is not registered for the family asked for.
999    #[error(transparent)]
1000    Selection(#[from] SelectionError),
1001    /// No key was given and none of these variables is set to a non-empty
1002    /// value.
1003    #[error(
1004        "no key for `{vendor}`: set {} or pass one with `ConnectOptions::api_key`",
1005        tried.iter().map(|name| format!("`{name}`")).collect::<Vec<_>>().join(" or ")
1006    )]
1007    MissingKey {
1008        /// The vendor.
1009        vendor: String,
1010        /// The variables read, in order: built-in names, or names a
1011        /// provider reads at run time.
1012        tried: Vec<Cow<'static, str>>,
1013    },
1014    /// The provider serving the model failed, in its own words, such as a
1015    /// [`Provide`](crate::catalog::Provide) whose gateway is not configured.
1016    #[error("{0}")]
1017    Provider(String),
1018    /// A variable the dialect reads is set but unusable.
1019    #[error(transparent)]
1020    Env(#[from] EnvError),
1021    /// No HTTP client was given, and this build has no shared one (the
1022    /// `reqwest` feature is off).
1023    #[error("no HTTP client: pass one with `ConnectOptions::http`")]
1024    NoHttpClient,
1025}
1026
1027impl Catalog {
1028    /// The completion model `model` selects, with the key from the
1029    /// environment, on the shared reqwest client. See
1030    /// [`Self::connect_with`].
1031    ///
1032    /// ```no_run
1033    /// use rig_core::catalog::Catalog;
1034    ///
1035    /// let catalog = Catalog::builtin();
1036    /// let model = catalog.connect("anthropic/claude-opus-5-5")?; // ANTHROPIC_API_KEY
1037    /// let spec = catalog.resolve("openai/gpt-5.5")?.spec;
1038    /// let other = catalog.connect(spec)?; // OPENAI_API_KEY
1039    /// # Ok::<(), Box<dyn std::error::Error>>(())
1040    /// ```
1041    #[cfg(feature = "reqwest")]
1042    #[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
1043    pub fn connect<'a>(
1044        &self,
1045        model: impl Into<ModelSelector<'a>>,
1046    ) -> Result<DynModel<Completion>, ConnectError> {
1047        self.connect_with(model, ConnectOptions::new())
1048    }
1049
1050    /// The completion model `model` selects, connected as `options` say.
1051    ///
1052    /// A reference is read as [`Self::resolve`] reads it, and the model id
1053    /// is sent as written. A model this catalog does not list still
1054    /// connects, on a vendor it knows. The family is `options`'s, else the
1055    /// one the older `vendor/format:model` spelling names, else the one the
1056    /// catalog row (or the spec) is reached by, else the vendor's primary.
1057    /// Unless `options` give a key, it is read from the first of the
1058    /// vendor's [`api_key_envs`](ProviderId::api_key_envs) set to a
1059    /// non-empty value, along with the base URL and other settings its
1060    /// dialect reads from the environment. An account `options` name
1061    /// replaces the one the dialect reads.
1062    ///
1063    /// The model encodes and prices with this catalog's facts: the spec it
1064    /// was connected from, and this catalog for any other model a request
1065    /// names, so the catalog's overrides reach its requests and reported
1066    /// cost.
1067    ///
1068    /// # Errors
1069    ///
1070    /// [`ConnectError::Malformed`] for a reference that is not
1071    /// `vendor/model`, [`ConnectError::NotFound`] for an unknown vendor,
1072    /// [`ConnectError::CatalogOnly`] for a provider a companion crate
1073    /// serves, [`ConnectError::Selection`] for a family the vendor is not
1074    /// registered for, [`ConnectError::MissingKey`] when no key is given or
1075    /// set, and [`ConnectError::NoHttpClient`] without the `reqwest`
1076    /// feature and an HTTP client.
1077    pub fn connect_with<'a>(
1078        &self,
1079        model: impl Into<ModelSelector<'a>>,
1080        options: ConnectOptions,
1081    ) -> Result<DynModel<Completion>, ConnectError> {
1082        let (provider, model, facts) = match model.into() {
1083            ModelSelector::Spec(spec) => (
1084                spec.provider,
1085                spec.id.as_str(),
1086                ModelFacts::new(spec.clone()).with_catalog(self),
1087            ),
1088            ModelSelector::Reference(text) => {
1089                let reference = Reference::parse(text).ok_or_else(|| ConnectError::Malformed {
1090                    reference: text.to_owned(),
1091                })?;
1092                let vendor =
1093                    ProviderId::catalog(reference.vendor).ok_or_else(|| self.not_found(text))?;
1094                let provider = self
1095                    .get_vendor(reference.vendor, reference.model)
1096                    .map_or(vendor, |resolved| resolved.spec.provider);
1097                let provider = match reference.format {
1098                    Some(format) if provider.is_registered() => {
1099                        ProviderId::selection(reference.vendor, format)?
1100                    }
1101                    _ => provider,
1102                };
1103                (
1104                    provider,
1105                    reference.model,
1106                    self.facts(provider, reference.model),
1107                )
1108            }
1109        };
1110        let provider = match options.format {
1111            Some(format) if provider.is_registered() => {
1112                ProviderId::selection(provider.vendor(), format)?
1113            }
1114            _ => provider,
1115        };
1116        let registered = provider.registered()?;
1117        let config = match options.api_key {
1118            Some(api_key) => registered.config(api_key),
1119            None => registered.config_from_env()?,
1120        };
1121        let config = match (config, options.account_id) {
1122            (ProviderConfig::OpenAi(provider), Some(account_id)) => {
1123                ProviderConfig::OpenAi(provider.with_account_id(account_id))
1124            }
1125            (config, _) => config,
1126        };
1127        let config = match options.base_url {
1128            Some(base_url) => config.with_base_url(base_url),
1129            None => config,
1130        };
1131        let http = match options.http {
1132            Some(http) => http,
1133            None => shared_http()?,
1134        };
1135        Ok(config.completion_model(model, http, facts))
1136    }
1137}
1138
1139/// The shared reqwest client.
1140#[cfg(feature = "reqwest")]
1141fn shared_http() -> Result<DynHttpClient, ConnectError> {
1142    Ok(rig_reqwest::shared())
1143}
1144
1145/// No shared client without the `reqwest` feature.
1146#[cfg(not(feature = "reqwest"))]
1147fn shared_http() -> Result<DynHttpClient, ConnectError> {
1148    Err(ConnectError::NoHttpClient)
1149}
1150
1151#[cfg(test)]
1152mod tests;