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