Skip to main content

leviath_runtime/
providers.rs

1//! The [`ProviderRegistry`]: a name → [`Provider`] lookup shared by the ECS
2//! pipeline (as the `Providers` resource) and the CLI/daemon spawn path.
3
4use crate::script_provider::ScriptProviderLayer;
5use leviath_providers::Provider;
6use std::collections::HashMap;
7use std::sync::Arc;
8
9/// Registry of inference providers, keyed by provider name (e.g. `"anthropic"`).
10///
11/// The pipeline resolves each agent's stage `ModelConfig` to a concrete
12/// provider through this registry. Native providers are registered eagerly;
13/// script providers are resolved lazily - and hot-reloaded - via
14/// an optional [`ScriptProviderLayer`].
15#[derive(Clone, Default)]
16pub struct ProviderRegistry {
17    providers: HashMap<String, Arc<dyn Provider>>,
18    /// Lazy, hot-reloading resolver for `.rhai` script providers. Shared across
19    /// registry clones (one compile cache daemon-wide).
20    script_layer: Option<Arc<ScriptProviderLayer>>,
21}
22
23impl ProviderRegistry {
24    /// Create a new empty provider registry.
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Attach a script-provider layer for lazy/hot-reloading `.rhai` providers.
30    pub fn with_script_layer(mut self, layer: Arc<ScriptProviderLayer>) -> Self {
31        self.script_layer = Some(layer);
32        self
33    }
34
35    /// Register a provider by name.
36    pub fn register(&mut self, name: String, provider: Arc<dyn Provider>) {
37        self.providers.insert(name, provider);
38    }
39
40    /// Get a provider by name, returning an owned handle.
41    ///
42    /// A native provider wins; otherwise the script layer is consulted, which
43    /// lazily compiles (or hot-reloads) the matching `.rhai` script.
44    pub fn get(&self, name: &str) -> Option<Arc<dyn Provider>> {
45        if let Some(p) = self.providers.get(name) {
46            return Some(p.clone());
47        }
48        self.script_layer.as_ref()?.get_or_load(name)
49    }
50
51    /// Let every registered provider learn what its own API says about its
52    /// models, before the first inference asks.
53    ///
54    /// Bounded and never fatal. A provider that cannot reach its API keeps its
55    /// built-in table, which is exactly the behaviour that existed before any
56    /// of this, so the worst case is the old answer rather than a daemon that
57    /// will not start. `timeout` covers each provider separately: this runs on
58    /// the start-up path, and an unreachable endpoint must cost a bounded wait
59    /// rather than however long a connect takes to give up.
60    ///
61    /// Script providers are deliberately not consulted. `get` compiles them on
62    /// demand, so priming would compile every `.rhai` provider on disk whether
63    /// or not the run touches one.
64    pub async fn prime_capabilities(&self, timeout: std::time::Duration) {
65        for (name, provider) in &self.providers {
66            match tokio::time::timeout(timeout, provider.prime_capabilities()).await {
67                Ok(Ok(())) => {}
68                Ok(Err(e)) => tracing::warn!(
69                    provider = %name,
70                    error = %e,
71                    "could not read this provider's model list, so model sizes \
72                     come from the table compiled into this build; a model it \
73                     does not name gets a conservative window"
74                ),
75                Err(_) => tracing::warn!(
76                    provider = %name,
77                    timeout_secs = timeout.as_secs(),
78                    "timed out reading this provider's model list, so model \
79                     sizes come from the table compiled into this build"
80                ),
81            }
82        }
83    }
84
85    /// Check if a provider is available: registered natively, or resolvable
86    /// (loadable) as a script provider right now. Used at stage-model selection;
87    /// network-free because script `initialize` runs offline.
88    pub fn has(&self, name: &str) -> bool {
89        self.providers.contains_key(name)
90            || self
91                .script_layer
92                .as_ref()
93                .is_some_and(|l| l.get_or_load(name).is_some())
94    }
95
96    /// Get all *natively-registered* provider names. Script providers are
97    /// resolved on demand and so are not enumerated here.
98    pub fn provider_names(&self) -> Vec<&str> {
99        self.providers.keys().map(|k| k.as_str()).collect()
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use leviath_providers::{
107        InferenceRequest, InferenceResponse, ModelCapabilities, ProviderError,
108    };
109
110    /// What a stub does when asked to prime.
111    enum PrimeOutcome {
112        Ok,
113        Fails,
114        Hangs,
115    }
116
117    struct StubProvider {
118        primed: Arc<std::sync::atomic::AtomicUsize>,
119        outcome: PrimeOutcome,
120    }
121
122    #[async_trait::async_trait]
123    impl Provider for StubProvider {
124        async fn prime_capabilities(&self) -> Result<(), ProviderError> {
125            self.primed
126                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
127            match self.outcome {
128                PrimeOutcome::Ok => Ok(()),
129                PrimeOutcome::Fails => Err(ProviderError::ApiError("no".to_string())),
130                PrimeOutcome::Hangs => {
131                    // Longer than any timeout a test passes, so the timeout arm
132                    // is what ends this rather than the sleep.
133                    tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
134                    Ok(())
135                }
136            }
137        }
138        async fn infer(
139            &self,
140            _request: &InferenceRequest,
141        ) -> Result<InferenceResponse, ProviderError> {
142            Err(ProviderError::ApiError("stub".to_string()))
143        }
144        async fn count_tokens(&self, text: &str, _model: &str) -> usize {
145            text.len()
146        }
147        fn max_context_tokens(&self, _model: &str) -> usize {
148            8192
149        }
150        fn name(&self) -> &str {
151            "stub"
152        }
153        fn capabilities(&self, _model: &str) -> ModelCapabilities {
154            ModelCapabilities::default()
155        }
156    }
157
158    fn mock() -> Arc<dyn Provider> {
159        Arc::new(StubProvider {
160            primed: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
161            outcome: PrimeOutcome::Ok,
162        })
163    }
164
165    #[test]
166    fn register_get_has_and_names() {
167        let mut reg = ProviderRegistry::new();
168        assert!(!reg.has("anthropic"));
169        assert!(reg.get("anthropic").is_none());
170        reg.register("anthropic".to_string(), mock());
171        assert!(reg.has("anthropic"));
172        assert!(reg.get("anthropic").is_some());
173        assert_eq!(reg.provider_names(), vec!["anthropic"]);
174    }
175
176    #[test]
177    fn default_is_empty() {
178        let reg = ProviderRegistry::default();
179        assert!(reg.provider_names().is_empty());
180    }
181
182    fn priming(outcome: PrimeOutcome) -> (Arc<dyn Provider>, Arc<std::sync::atomic::AtomicUsize>) {
183        let primed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
184        (
185            Arc::new(StubProvider {
186                primed: primed.clone(),
187                outcome,
188            }),
189            primed,
190        )
191    }
192
193    #[tokio::test]
194    async fn priming_reaches_every_registered_provider() {
195        let mut reg = ProviderRegistry::new();
196        let (p, primed) = priming(PrimeOutcome::Ok);
197        reg.register("prime".to_string(), p);
198        reg.register("other".to_string(), mock());
199
200        reg.prime_capabilities(std::time::Duration::from_secs(5))
201            .await;
202        assert_eq!(primed.load(std::sync::atomic::Ordering::Relaxed), 1);
203    }
204
205    /// A provider that cannot answer is a warning, not a failure: the daemon
206    /// has to start whether or not an API is reachable.
207    #[tokio::test]
208    async fn a_failing_prime_does_not_stop_the_rest() {
209        let mut reg = ProviderRegistry::new();
210        let (bad, bad_calls) = priming(PrimeOutcome::Fails);
211        reg.register("bad".to_string(), bad);
212        reg.prime_capabilities(std::time::Duration::from_secs(5))
213            .await;
214        assert_eq!(bad_calls.load(std::sync::atomic::Ordering::Relaxed), 1);
215    }
216
217    /// An endpoint that never answers costs the timeout, not the start-up.
218    #[tokio::test(start_paused = true)]
219    async fn priming_gives_up_on_a_provider_that_hangs() {
220        let mut reg = ProviderRegistry::new();
221        let (slow, calls) = priming(PrimeOutcome::Hangs);
222        reg.register("slow".to_string(), slow);
223        // With the clock paused this returns as soon as the timeout is the only
224        // thing left to wait on, so a regression here fails by hanging the
225        // suite rather than by sleeping through it.
226        reg.prime_capabilities(std::time::Duration::from_secs(10))
227            .await;
228        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
229    }
230
231    #[test]
232    fn script_layer_resolves_and_native_wins() {
233        use crate::script_provider::ScriptProviderLayer;
234        use std::collections::HashMap;
235
236        let dir = tempfile::tempdir().unwrap();
237        std::fs::write(
238            dir.path().join("groq.rhai"),
239            "fn initialize(config) { #{} }\nfn inference(state, request) { #{ content: \"ok\" } }",
240        )
241        .unwrap();
242        let layer = ScriptProviderLayer::new(
243            dir.path().to_path_buf(),
244            HashMap::new(),
245            HashMap::new(),
246            None,
247            Vec::new(),
248        );
249        let mut reg = ProviderRegistry::new().with_script_layer(Arc::new(layer));
250        reg.register("anthropic".to_string(), mock());
251
252        // Native provider still wins and is found by name.
253        assert!(reg.has("anthropic"));
254        assert!(reg.get("anthropic").is_some());
255
256        // A script provider is resolved lazily through the layer by both has/get.
257        assert!(reg.has("groq"));
258        let p = reg.get("groq").expect("script provider resolves");
259        assert_eq!(p.name(), "groq");
260
261        // An unknown name resolves to nothing (layer returns None).
262        assert!(!reg.has("nope"));
263        assert!(reg.get("nope").is_none());
264    }
265
266    #[tokio::test]
267    async fn stub_provider_methods_are_exercised() {
268        let p = StubProvider {
269            primed: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
270            outcome: PrimeOutcome::Ok,
271        };
272        assert_eq!(p.name(), "stub");
273        assert_eq!(p.count_tokens("abcd", "m").await, 4);
274        assert_eq!(p.max_context_tokens("m"), 8192);
275        let _ = p.capabilities("m");
276        let request = InferenceRequest {
277            system: Vec::new(),
278            messages: Vec::new(),
279            model: "m".to_string(),
280            max_tokens: 10,
281            temperature: 0.0,
282            tools: Vec::new(),
283            extra: serde_json::Value::Null,
284            request_timeout_secs: None,
285        };
286        assert!(p.infer(&request).await.is_err());
287    }
288}