Skip to main content

fxrs_provider/
lib.rs

1//! Provider registry and provider-neutral authentication contracts.
2//!
3//! A provider owns its model catalog, authentication lifecycle, and gateway
4//! construction. The registry intentionally supports several providers in one
5//! process; selecting a model is therefore also selecting its provider.
6
7pub mod codex;
8mod transport;
9pub mod vercel;
10
11pub use codex::{CodexProvider, CodexProviderConfig};
12pub use transport::VercelRoutingPolicy;
13pub use vercel::{VercelProvider, VercelProviderConfig};
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::fmt;
17use std::sync::{Arc, RwLock};
18
19use fx_core::Gateway;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use zeroize::Zeroize;
23
24pub const MODEL_ROUTE_SEPARATOR: char = '/';
25
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct Model {
28    pub provider_id: String,
29    pub id: String,
30    pub name: String,
31    pub context_window: u32,
32    pub max_output_tokens: u32,
33    pub reasoning: bool,
34    pub capabilities: ModelCapabilities,
35}
36
37impl Model {
38    pub fn route(&self) -> String {
39        format!("{}{MODEL_ROUTE_SEPARATOR}{}", self.provider_id, self.id)
40    }
41}
42
43#[derive(Clone, Debug, Default, Eq, PartialEq)]
44pub struct ModelCapabilities {
45    pub native_web_search: Option<NativeWebSearch>,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct NativeWebSearch {
50    pub provider_tool_id: String,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct AuthMethod {
55    pub id: String,
56    pub name: String,
57    pub description: String,
58}
59
60impl AuthMethod {
61    pub fn new(
62        id: impl Into<String>,
63        name: impl Into<String>,
64        description: impl Into<String>,
65    ) -> Self {
66        Self {
67            id: id.into(),
68            name: name.into(),
69            description: description.into(),
70        }
71    }
72}
73
74/// Durable provider credential. Providers may add non-secret routing fields
75/// (for example an account id) without changing the store schema.
76#[derive(Clone, Deserialize, Serialize)]
77#[serde(tag = "kind", rename_all = "snake_case")]
78pub enum Credential {
79    ApiKey {
80        secret: String,
81        #[serde(default)]
82        attributes: BTreeMap<String, String>,
83    },
84    OAuth {
85        access_token: String,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        refresh_token: Option<String>,
88        expires_at_ms: i64,
89        #[serde(default)]
90        attributes: BTreeMap<String, String>,
91    },
92}
93
94impl Drop for Credential {
95    fn drop(&mut self) {
96        match self {
97            Self::ApiKey { secret, attributes } => {
98                secret.zeroize();
99                for value in attributes.values_mut() {
100                    value.zeroize();
101                }
102            }
103            Self::OAuth {
104                access_token,
105                refresh_token,
106                attributes,
107                ..
108            } => {
109                access_token.zeroize();
110                refresh_token.zeroize();
111                for value in attributes.values_mut() {
112                    value.zeroize();
113                }
114            }
115        }
116    }
117}
118
119impl fmt::Debug for Credential {
120    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::ApiKey { attributes, .. } => formatter
123                .debug_struct("ApiKeyCredential")
124                .field("secret", &"[redacted]")
125                .field("attributes", attributes)
126                .finish(),
127            Self::OAuth {
128                refresh_token,
129                expires_at_ms,
130                attributes,
131                ..
132            } => formatter
133                .debug_struct("OAuthCredential")
134                .field("access_token", &"[redacted]")
135                .field(
136                    "refresh_token",
137                    &refresh_token.as_ref().map(|_| "[redacted]"),
138                )
139                .field("expires_at_ms", expires_at_ms)
140                .field("attributes", attributes)
141                .finish(),
142        }
143    }
144}
145
146/// An exclusive provider credential lease. Implementations hold their lock
147/// until this value is dropped, including while a provider refreshes OAuth.
148pub trait CredentialLease {
149    fn credential(&self) -> Option<&Credential>;
150    fn replace(&mut self, credential: Credential) -> Result<(), ProviderError>;
151    fn delete(&mut self) -> Result<(), ProviderError>;
152}
153
154pub trait CredentialStore: Send + Sync {
155    fn lock<'a>(
156        &'a self,
157        provider_id: &str,
158    ) -> Result<Box<dyn CredentialLease + 'a>, ProviderError>;
159}
160
161pub trait Provider: Send + Sync {
162    fn id(&self) -> &str;
163    fn name(&self) -> &str;
164    fn models(&self) -> Vec<Model>;
165    fn default_model(&self) -> &str;
166    fn auth_methods(&self) -> Vec<AuthMethod>;
167
168    /// Performs an interactive authentication method and persists only Fx's
169    /// owned credential. This is called on a blocking worker by the runtime.
170    fn authenticate(
171        &self,
172        method_id: &str,
173        credentials: &dyn CredentialStore,
174    ) -> Result<(), ProviderError>;
175
176    /// Reloads a provider's credential-scoped model catalog. Providers with a
177    /// static catalog can keep the default implementation. Hosts invoke this
178    /// after authentication or once a session is active, never while building
179    /// the startup registry, so cold initialization stays network-free.
180    fn refresh_models(
181        &self,
182        _credentials: &dyn CredentialStore,
183    ) -> Result<Option<Vec<Model>>, ProviderError> {
184        Ok(None)
185    }
186
187    /// Removes fxrs-owned authentication state. Ambient state belonging to
188    /// another application must never be modified.
189    fn logout(&self, credentials: &dyn CredentialStore) -> Result<(), ProviderError> {
190        let mut lease = credentials.lock(self.id())?;
191        lease.delete()
192    }
193
194    /// Resolves/refreshes authentication and constructs a transport for one
195    /// provider-local model id.
196    fn gateway(
197        &self,
198        model_id: &str,
199        session_id: Option<&str>,
200        credentials: &dyn CredentialStore,
201    ) -> Result<Arc<dyn Gateway>, ProviderError>;
202}
203
204#[derive(Debug, Error)]
205pub enum ProviderError {
206    #[error("provider `{0}` is not registered")]
207    UnknownProvider(String),
208    #[error("model `{0}` is not registered")]
209    UnknownModel(String),
210    #[error("authentication method `{0}` is not registered")]
211    UnknownAuthMethod(String),
212    #[error("provider `{0}` is already registered")]
213    DuplicateProvider(String),
214    #[error("model route `{0}` is already registered")]
215    DuplicateModel(String),
216    #[error("authentication method `{0}` is already registered")]
217    DuplicateAuthMethod(String),
218    #[error("authentication is required for {provider}: {message}")]
219    AuthenticationRequired { provider: String, message: String },
220    #[error("provider authentication failed: {0}")]
221    Authentication(String),
222    #[error("provider credential store failed: {0}")]
223    CredentialStore(String),
224    #[error("provider configuration is invalid: {0}")]
225    Configuration(String),
226    #[error("provider transport could not be created: {0}")]
227    Transport(String),
228}
229
230#[derive(Clone, Debug, Default, Eq, PartialEq)]
231pub struct AuthenticationOutcome {
232    pub models_refreshed: bool,
233    /// Authentication remains successful if an optional catalog refresh
234    /// fails. Callers may surface this warning without asking users to log in
235    /// again; the registry keeps its previous catalog unchanged.
236    pub catalog_warning: Option<String>,
237}
238
239#[derive(Default)]
240pub struct ProviderRegistry {
241    providers: BTreeMap<String, Arc<dyn Provider>>,
242    models: Arc<RwLock<BTreeMap<String, Model>>>,
243    auth_methods: BTreeMap<String, RegisteredAuthMethod>,
244    default_model_route: Option<String>,
245}
246
247impl Clone for ProviderRegistry {
248    fn clone(&self) -> Self {
249        Self {
250            providers: self.providers.clone(),
251            models: Arc::new(RwLock::new(read_models(&self.models).clone())),
252            auth_methods: self.auth_methods.clone(),
253            default_model_route: self.default_model_route.clone(),
254        }
255    }
256}
257
258#[derive(Clone)]
259struct RegisteredAuthMethod {
260    provider_id: String,
261    local_id: String,
262    descriptor: AuthMethod,
263}
264
265impl ProviderRegistry {
266    pub fn new() -> Self {
267        Self::default()
268    }
269
270    pub fn register(&mut self, provider: Arc<dyn Provider>) -> Result<(), ProviderError> {
271        validate_component("provider id", provider.id())?;
272        if self.providers.contains_key(provider.id()) {
273            return Err(ProviderError::DuplicateProvider(provider.id().into()));
274        }
275
276        let mut models = Vec::new();
277        let mut seen_models = BTreeSet::new();
278        for model in provider.models() {
279            validate_model_id(&model.id)?;
280            if model.provider_id != provider.id() {
281                return Err(ProviderError::Configuration(format!(
282                    "model `{}` declares provider `{}` instead of `{}`",
283                    model.id,
284                    model.provider_id,
285                    provider.id()
286                )));
287            }
288            let route = model.route();
289            if !seen_models.insert(route.clone()) {
290                return Err(ProviderError::DuplicateModel(route));
291            }
292            models.push((route, model));
293        }
294        if models.is_empty() {
295            return Err(ProviderError::Configuration(format!(
296                "provider `{}` has no models",
297                provider.id()
298            )));
299        }
300        let default_route = format!("{}/{}", provider.id(), provider.default_model());
301        if !models.iter().any(|(route, _)| route == &default_route) {
302            return Err(ProviderError::Configuration(format!(
303                "provider `{}` default model `{}` is not in its catalog",
304                provider.id(),
305                provider.default_model()
306            )));
307        }
308
309        let mut methods = Vec::new();
310        let mut seen_methods = BTreeSet::new();
311        for method in provider.auth_methods() {
312            validate_component("authentication method id", &method.id)?;
313            let global_id = auth_route(provider.id(), &method.id);
314            if self.auth_methods.contains_key(&global_id) || !seen_methods.insert(global_id.clone())
315            {
316                return Err(ProviderError::DuplicateAuthMethod(global_id));
317            }
318            let mut descriptor = method.clone();
319            descriptor.id = global_id.clone();
320            methods.push((
321                global_id,
322                RegisteredAuthMethod {
323                    provider_id: provider.id().into(),
324                    local_id: method.id,
325                    descriptor,
326                },
327            ));
328        }
329
330        {
331            let mut registered = write_models(&self.models);
332            if let Some((route, _)) = models
333                .iter()
334                .find(|(route, _)| registered.contains_key(route))
335            {
336                return Err(ProviderError::DuplicateModel(route.clone()));
337            }
338            registered.extend(models);
339        }
340        self.auth_methods.extend(methods);
341        if self.default_model_route.is_none() {
342            self.default_model_route = Some(default_route);
343        }
344        self.providers.insert(provider.id().into(), provider);
345        Ok(())
346    }
347
348    pub fn models(&self) -> Vec<Model> {
349        read_models(&self.models).values().cloned().collect()
350    }
351
352    pub fn model(&self, route: &str) -> Result<Model, ProviderError> {
353        read_models(&self.models)
354            .get(route)
355            .cloned()
356            .ok_or_else(|| ProviderError::UnknownModel(route.into()))
357    }
358
359    pub fn default_model(&self) -> Result<Model, ProviderError> {
360        let route = self
361            .default_model_route
362            .as_deref()
363            .ok_or_else(|| ProviderError::Configuration("provider registry is empty".into()))?;
364        self.model(route)
365    }
366
367    pub fn auth_methods(&self) -> Vec<AuthMethod> {
368        self.auth_methods
369            .values()
370            .map(|method| method.descriptor.clone())
371            .collect()
372    }
373
374    pub fn authenticate(
375        &self,
376        method_id: &str,
377        credentials: &dyn CredentialStore,
378    ) -> Result<AuthenticationOutcome, ProviderError> {
379        let method = self
380            .auth_methods
381            .get(method_id)
382            .ok_or_else(|| ProviderError::UnknownAuthMethod(method_id.into()))?;
383        let provider = &self.providers[&method.provider_id];
384        provider.authenticate(&method.local_id, credentials)?;
385
386        Ok(self.refresh_provider_catalog(&method.provider_id, credentials))
387    }
388
389    /// Refreshes every provider that exposes a credential-scoped catalog.
390    /// Individual failures do not discard successful updates or the previous
391    /// catalog of the failed provider.
392    pub fn refresh_models(&self, credentials: &dyn CredentialStore) -> AuthenticationOutcome {
393        let mut outcome = AuthenticationOutcome::default();
394        let mut warnings = Vec::new();
395        for provider_id in self.providers.keys() {
396            let refreshed = self.refresh_provider_catalog(provider_id, credentials);
397            outcome.models_refreshed |= refreshed.models_refreshed;
398            if let Some(warning) = refreshed.catalog_warning {
399                warnings.push(format!("{provider_id}: {warning}"));
400            }
401        }
402        if !warnings.is_empty() {
403            outcome.catalog_warning = Some(warnings.join("; "));
404        }
405        outcome
406    }
407
408    fn refresh_provider_catalog(
409        &self,
410        provider_id: &str,
411        credentials: &dyn CredentialStore,
412    ) -> AuthenticationOutcome {
413        let provider = &self.providers[provider_id];
414        match provider.refresh_models(credentials) {
415            Ok(Some(models)) => match self.replace_provider_models(provider_id, models) {
416                Ok(models_refreshed) => AuthenticationOutcome {
417                    models_refreshed,
418                    catalog_warning: None,
419                },
420                Err(error) => AuthenticationOutcome {
421                    models_refreshed: false,
422                    catalog_warning: Some(error.to_string()),
423                },
424            },
425            Ok(None) => AuthenticationOutcome::default(),
426            Err(error) => AuthenticationOutcome {
427                models_refreshed: false,
428                catalog_warning: Some(error.to_string()),
429            },
430        }
431    }
432
433    /// Atomically replaces one provider's catalog after validating the full
434    /// candidate set. Other providers remain visible throughout the update.
435    pub fn replace_provider_models(
436        &self,
437        provider_id: &str,
438        models: Vec<Model>,
439    ) -> Result<bool, ProviderError> {
440        let provider = self
441            .providers
442            .get(provider_id)
443            .ok_or_else(|| ProviderError::UnknownProvider(provider_id.into()))?;
444        let mut replacement = BTreeMap::new();
445        for model in models {
446            validate_model_id(&model.id)?;
447            if model.provider_id != provider_id {
448                return Err(ProviderError::Configuration(format!(
449                    "model `{}` declares provider `{}` instead of `{provider_id}`",
450                    model.id, model.provider_id
451                )));
452            }
453            let route = model.route();
454            if replacement.insert(route.clone(), model).is_some() {
455                return Err(ProviderError::DuplicateModel(route));
456            }
457        }
458        if replacement.is_empty() {
459            return Err(ProviderError::Configuration(format!(
460                "provider `{provider_id}` has no models"
461            )));
462        }
463        let default_route = format!("{provider_id}/{}", provider.default_model());
464        if !replacement.contains_key(&default_route) {
465            return Err(ProviderError::Configuration(format!(
466                "provider `{provider_id}` default model `{}` is not in its refreshed catalog",
467                provider.default_model()
468            )));
469        }
470
471        let mut registered = write_models(&self.models);
472        for route in replacement.keys() {
473            if registered
474                .get(route)
475                .is_some_and(|model| model.provider_id != provider_id)
476            {
477                return Err(ProviderError::DuplicateModel(route.clone()));
478            }
479        }
480        let mut updated = registered
481            .iter()
482            .filter(|(_, model)| model.provider_id != provider_id)
483            .map(|(route, model)| (route.clone(), model.clone()))
484            .collect::<BTreeMap<_, _>>();
485        updated.extend(replacement);
486        if *registered == updated {
487            return Ok(false);
488        }
489        *registered = updated;
490        Ok(true)
491    }
492
493    pub fn logout_all(&self, credentials: &dyn CredentialStore) -> Result<(), ProviderError> {
494        let mut failures = Vec::new();
495        for provider in self.providers.values() {
496            if let Err(error) = provider.logout(credentials) {
497                failures.push(format!("{}: {error}", provider.id()));
498            }
499        }
500        if failures.is_empty() {
501            Ok(())
502        } else {
503            Err(ProviderError::CredentialStore(failures.join("; ")))
504        }
505    }
506
507    pub fn gateway(
508        &self,
509        route: &str,
510        session_id: Option<&str>,
511        credentials: &dyn CredentialStore,
512    ) -> Result<Arc<dyn Gateway>, ProviderError> {
513        let model = self.model(route)?;
514        self.providers[&model.provider_id].gateway(&model.id, session_id, credentials)
515    }
516}
517
518impl fmt::Debug for ProviderRegistry {
519    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
520        formatter
521            .debug_struct("ProviderRegistry")
522            .field("providers", &self.providers.keys().collect::<Vec<_>>())
523            .field(
524                "models",
525                &read_models(&self.models)
526                    .keys()
527                    .cloned()
528                    .collect::<Vec<_>>(),
529            )
530            .field(
531                "auth_methods",
532                &self.auth_methods.keys().collect::<Vec<_>>(),
533            )
534            .finish()
535    }
536}
537
538fn read_models(
539    models: &RwLock<BTreeMap<String, Model>>,
540) -> std::sync::RwLockReadGuard<'_, BTreeMap<String, Model>> {
541    models
542        .read()
543        .unwrap_or_else(std::sync::PoisonError::into_inner)
544}
545
546fn write_models(
547    models: &RwLock<BTreeMap<String, Model>>,
548) -> std::sync::RwLockWriteGuard<'_, BTreeMap<String, Model>> {
549    models
550        .write()
551        .unwrap_or_else(std::sync::PoisonError::into_inner)
552}
553
554fn auth_route(provider_id: &str, method_id: &str) -> String {
555    format!("{provider_id}:{method_id}")
556}
557
558fn validate_component(label: &str, value: &str) -> Result<(), ProviderError> {
559    let valid = !value.is_empty()
560        && value.len() <= 128
561        && !value.contains(MODEL_ROUTE_SEPARATOR)
562        && value
563            .bytes()
564            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
565    if valid {
566        Ok(())
567    } else {
568        Err(ProviderError::Configuration(format!(
569            "{label} `{value}` is not a safe identifier"
570        )))
571    }
572}
573
574fn validate_model_id(value: &str) -> Result<(), ProviderError> {
575    let valid = !value.is_empty()
576        && value.len() <= 256
577        && value
578            .split(MODEL_ROUTE_SEPARATOR)
579            .all(|component| validate_component("model id component", component).is_ok());
580    if valid {
581        Ok(())
582    } else {
583        Err(ProviderError::Configuration(format!(
584            "model id `{value}` is not a safe slash-separated identifier"
585        )))
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use std::sync::Mutex;
592
593    use super::*;
594    use fx_core::{BoxFuture, GatewayError, GatewayEventSink, GatewayRequest, GatewayResponse};
595
596    #[derive(Default)]
597    struct MemoryStore(Mutex<Option<Credential>>);
598
599    struct MemoryLease<'a>(std::sync::MutexGuard<'a, Option<Credential>>);
600
601    impl CredentialLease for MemoryLease<'_> {
602        fn credential(&self) -> Option<&Credential> {
603            self.0.as_ref()
604        }
605
606        fn replace(&mut self, credential: Credential) -> Result<(), ProviderError> {
607            *self.0 = Some(credential);
608            Ok(())
609        }
610
611        fn delete(&mut self) -> Result<(), ProviderError> {
612            *self.0 = None;
613            Ok(())
614        }
615    }
616
617    impl CredentialStore for MemoryStore {
618        fn lock<'a>(
619            &'a self,
620            _provider_id: &str,
621        ) -> Result<Box<dyn CredentialLease + 'a>, ProviderError> {
622            Ok(Box::new(MemoryLease(self.0.lock().unwrap())))
623        }
624    }
625
626    struct EmptyGateway;
627
628    impl Gateway for EmptyGateway {
629        fn complete<'a>(
630            &'a self,
631            _request: GatewayRequest,
632            _events: &'a mut dyn GatewayEventSink,
633        ) -> BoxFuture<'a, Result<GatewayResponse, GatewayError>> {
634            Box::pin(async { Ok(GatewayResponse::default()) })
635        }
636    }
637
638    struct TestProvider(&'static str);
639
640    impl Provider for TestProvider {
641        fn id(&self) -> &str {
642            self.0
643        }
644
645        fn name(&self) -> &str {
646            self.0
647        }
648
649        fn models(&self) -> Vec<Model> {
650            vec![Model {
651                provider_id: self.0.into(),
652                id: "model".into(),
653                name: "Model".into(),
654                context_window: 1,
655                max_output_tokens: 1,
656                reasoning: false,
657                capabilities: ModelCapabilities::default(),
658            }]
659        }
660
661        fn default_model(&self) -> &str {
662            "model"
663        }
664
665        fn auth_methods(&self) -> Vec<AuthMethod> {
666            vec![AuthMethod::new("login", "Login", "Login")]
667        }
668
669        fn authenticate(
670            &self,
671            _method_id: &str,
672            _credentials: &dyn CredentialStore,
673        ) -> Result<(), ProviderError> {
674            Ok(())
675        }
676
677        fn gateway(
678            &self,
679            _model_id: &str,
680            _session_id: Option<&str>,
681            _credentials: &dyn CredentialStore,
682        ) -> Result<Arc<dyn Gateway>, ProviderError> {
683            Ok(Arc::new(EmptyGateway))
684        }
685    }
686
687    struct RefreshingProvider;
688
689    impl Provider for RefreshingProvider {
690        fn id(&self) -> &str {
691            "dynamic"
692        }
693
694        fn name(&self) -> &str {
695            "Dynamic"
696        }
697
698        fn models(&self) -> Vec<Model> {
699            vec![test_model("dynamic", "model")]
700        }
701
702        fn default_model(&self) -> &str {
703            "model"
704        }
705
706        fn auth_methods(&self) -> Vec<AuthMethod> {
707            vec![AuthMethod::new("login", "Login", "Login")]
708        }
709
710        fn authenticate(
711            &self,
712            _method_id: &str,
713            _credentials: &dyn CredentialStore,
714        ) -> Result<(), ProviderError> {
715            Ok(())
716        }
717
718        fn refresh_models(
719            &self,
720            _credentials: &dyn CredentialStore,
721        ) -> Result<Option<Vec<Model>>, ProviderError> {
722            Ok(Some(vec![
723                test_model("dynamic", "model"),
724                test_model("dynamic", "new/model"),
725            ]))
726        }
727
728        fn gateway(
729            &self,
730            _model_id: &str,
731            _session_id: Option<&str>,
732            _credentials: &dyn CredentialStore,
733        ) -> Result<Arc<dyn Gateway>, ProviderError> {
734            Ok(Arc::new(EmptyGateway))
735        }
736    }
737
738    fn test_model(provider_id: &str, id: &str) -> Model {
739        Model {
740            provider_id: provider_id.into(),
741            id: id.into(),
742            name: id.into(),
743            context_window: 1,
744            max_output_tokens: 1,
745            reasoning: false,
746            capabilities: ModelCapabilities::default(),
747        }
748    }
749
750    #[test]
751    fn registry_routes_multiple_providers_without_global_state() {
752        let mut registry = ProviderRegistry::new();
753        registry.register(Arc::new(TestProvider("alpha"))).unwrap();
754        registry.register(Arc::new(TestProvider("beta"))).unwrap();
755        assert_eq!(
756            registry
757                .models()
758                .iter()
759                .map(Model::route)
760                .collect::<Vec<_>>(),
761            ["alpha/model", "beta/model"]
762        );
763        assert_eq!(
764            registry
765                .auth_methods()
766                .iter()
767                .map(|method| method.id.as_str())
768                .collect::<Vec<_>>(),
769            ["alpha:login", "beta:login"]
770        );
771        assert!(
772            registry
773                .gateway("beta/model", None, &MemoryStore::default())
774                .is_ok()
775        );
776    }
777
778    #[test]
779    fn registration_is_transactional() {
780        let mut registry = ProviderRegistry::new();
781        registry.register(Arc::new(TestProvider("alpha"))).unwrap();
782        assert!(registry.register(Arc::new(TestProvider("alpha"))).is_err());
783        assert_eq!(registry.models().len(), 1);
784    }
785
786    #[test]
787    fn authentication_atomically_refreshes_only_its_provider_models() {
788        let mut registry = ProviderRegistry::new();
789        registry.register(Arc::new(TestProvider("stable"))).unwrap();
790        registry.register(Arc::new(RefreshingProvider)).unwrap();
791
792        let outcome = registry
793            .authenticate("dynamic:login", &MemoryStore::default())
794            .unwrap();
795        assert!(outcome.models_refreshed);
796        assert!(outcome.catalog_warning.is_none());
797        assert!(registry.model("dynamic/new/model").is_ok());
798        assert!(registry.model("stable/model").is_ok());
799
800        let before = registry.models();
801        assert!(
802            registry
803                .replace_provider_models("dynamic", vec![test_model("other", "model")])
804                .is_err()
805        );
806        assert_eq!(registry.models(), before);
807    }
808
809    struct NestedModelProvider;
810
811    impl Provider for NestedModelProvider {
812        fn id(&self) -> &str {
813            "vercel"
814        }
815
816        fn name(&self) -> &str {
817            "Vercel AI Gateway"
818        }
819
820        fn models(&self) -> Vec<Model> {
821            vec![Model {
822                provider_id: "vercel".into(),
823                id: "zai/glm-5.2".into(),
824                name: "GLM 5.2".into(),
825                context_window: 1,
826                max_output_tokens: 1,
827                reasoning: true,
828                capabilities: ModelCapabilities::default(),
829            }]
830        }
831
832        fn default_model(&self) -> &str {
833            "zai/glm-5.2"
834        }
835
836        fn auth_methods(&self) -> Vec<AuthMethod> {
837            Vec::new()
838        }
839
840        fn authenticate(
841            &self,
842            method_id: &str,
843            _credentials: &dyn CredentialStore,
844        ) -> Result<(), ProviderError> {
845            Err(ProviderError::UnknownAuthMethod(method_id.into()))
846        }
847
848        fn gateway(
849            &self,
850            _model_id: &str,
851            _session_id: Option<&str>,
852            _credentials: &dyn CredentialStore,
853        ) -> Result<Arc<dyn Gateway>, ProviderError> {
854            Ok(Arc::new(EmptyGateway))
855        }
856    }
857
858    #[test]
859    fn registry_accepts_provider_local_model_paths() {
860        let mut registry = ProviderRegistry::new();
861        registry.register(Arc::new(NestedModelProvider)).unwrap();
862        assert_eq!(
863            registry.default_model().unwrap().route(),
864            "vercel/zai/glm-5.2"
865        );
866    }
867}