Skip to main content

jamjet_models/
registry.rs

1//! Model registry — resolves model names to adapters.
2//!
3//! Supports routing rules: e.g. "claude-*" → Anthropic, "gpt-*" → OpenAI.
4//! Falls back to a default adapter if no rule matches.
5
6use crate::adapter::{ModelAdapter, ModelError, ModelRequest, ModelResponse, StructuredRequest};
7use std::collections::HashMap;
8use std::sync::Arc;
9
10/// Routes model requests to the appropriate adapter.
11///
12/// Register adapters by `system_name()` (e.g. "anthropic", "openai").
13/// The registry selects an adapter based on the model prefix in the request config,
14/// or falls back to the default adapter.
15pub struct ModelRegistry {
16    adapters: HashMap<String, Arc<dyn ModelAdapter>>,
17    /// Prefix routing: model name prefix → system name (e.g. "claude-" → "anthropic").
18    prefix_routes: Vec<(String, String)>,
19    default: Option<String>,
20}
21
22impl ModelRegistry {
23    pub fn new() -> Self {
24        Self {
25            adapters: HashMap::new(),
26            prefix_routes: Vec::new(),
27            default: None,
28        }
29    }
30
31    /// Register an adapter under its system name.
32    pub fn register(mut self, adapter: Arc<dyn ModelAdapter>) -> Self {
33        let name = adapter.system_name().to_string();
34        self.adapters.insert(name, adapter);
35        self
36    }
37
38    /// Route model name prefix to a system (e.g. "claude-" → "anthropic").
39    pub fn route_prefix(mut self, prefix: impl Into<String>, system: impl Into<String>) -> Self {
40        self.prefix_routes.push((prefix.into(), system.into()));
41        self
42    }
43
44    /// Set the default adapter to use when no prefix matches.
45    pub fn with_default(mut self, system: impl Into<String>) -> Self {
46        self.default = Some(system.into());
47        self
48    }
49
50    /// Resolve an adapter for the given model name.
51    fn resolve(&self, model: &str) -> Option<Arc<dyn ModelAdapter>> {
52        // Check prefix routes first.
53        for (prefix, system) in &self.prefix_routes {
54            if model.starts_with(prefix.as_str()) {
55                if let Some(adapter) = self.adapters.get(system) {
56                    return Some(Arc::clone(adapter));
57                }
58            }
59        }
60        // Fall back to default.
61        if let Some(default) = &self.default {
62            return self.adapters.get(default).map(Arc::clone);
63        }
64        // Only one adapter registered — use it.
65        if self.adapters.len() == 1 {
66            return self.adapters.values().next().map(Arc::clone);
67        }
68        None
69    }
70
71    /// Send a chat request, routing to the appropriate adapter.
72    pub async fn chat(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
73        let model = request.config.model.clone().unwrap_or_default();
74        let adapter = self
75            .resolve(&model)
76            .ok_or_else(|| ModelError::Network(format!("no adapter for model: {model}")))?;
77        adapter.chat(request).await
78    }
79
80    /// Send a structured output request, routing to the appropriate adapter.
81    pub async fn structured_output(
82        &self,
83        request: StructuredRequest,
84    ) -> Result<ModelResponse, ModelError> {
85        let model = request.config.model.clone().unwrap_or_default();
86        let adapter = self
87            .resolve(&model)
88            .ok_or_else(|| ModelError::Network(format!("no adapter for model: {model}")))?;
89        adapter.structured_output(request).await
90    }
91}
92
93impl Default for ModelRegistry {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl ModelRegistry {
100    /// The system name of the current default adapter, if one is set.
101    ///
102    /// Primarily for tests and introspection — not on the hot path.
103    pub fn default_system(&self) -> Option<&str> {
104        self.default.as_deref()
105    }
106}
107
108/// Build a `ModelRegistry` from environment variables.
109///
110/// Registers adapters based on available API keys / services:
111/// - Anthropic if `ANTHROPIC_API_KEY` is set
112/// - OpenAI if `OPENAI_API_KEY` is set
113/// - Google if `GOOGLE_API_KEY` or `GEMINI_API_KEY` is set
114/// - Ollama if `OLLAMA_HOST` is set or defaults to localhost:11434
115///
116/// If `JAMJET_MODEL_SEAM_URL` is set, all native adapters and prefix routes are
117/// DISCARDED and a sidecar-only registry is returned via [`apply_sidecar`].
118/// Every model string — with or without a provider prefix — routes to the
119/// governed Python seam.  No native bypass paths survive in seam mode.
120///
121/// Sets up standard prefix routing:
122///   claude-* → anthropic, gpt-*/o1-*/o3-* → openai,
123///   gemini-* → google, ollama model names → ollama.
124///
125/// **Does NOT probe the sidecar health endpoint.** Call
126/// [`registry_from_env_checked`] at startup if you need the fail-loud guard.
127pub fn registry_from_env() -> ModelRegistry {
128    use crate::{
129        anthropic::AnthropicAdapter, google::GoogleAdapter, ollama::OllamaAdapter,
130        openai::OpenAiAdapter,
131    };
132
133    let mut registry = ModelRegistry::new()
134        // Fully-qualified provider-prefixed strings (e.g. "anthropic/claude-sonnet-4-6").
135        .route_prefix("anthropic/", "anthropic")
136        .route_prefix("openai/", "openai")
137        .route_prefix("google/", "google")
138        // Bare model-name prefixes for backwards compat.
139        .route_prefix("claude-", "anthropic")
140        .route_prefix("gpt-", "openai")
141        .route_prefix("o1-", "openai")
142        .route_prefix("o3-", "openai")
143        .route_prefix("gemini-", "google")
144        // Common Ollama model name patterns.
145        .route_prefix("llama", "ollama")
146        .route_prefix("qwen", "ollama")
147        .route_prefix("gemma", "ollama")
148        .route_prefix("phi", "ollama")
149        .route_prefix("mistral", "ollama")
150        .route_prefix("codellama", "ollama")
151        .route_prefix("deepseek", "ollama")
152        .route_prefix("nomic-", "ollama");
153
154    if let Ok(adapter) = AnthropicAdapter::from_env() {
155        registry = registry.register(Arc::new(adapter));
156        registry = registry.with_default("anthropic");
157    }
158
159    if let Ok(adapter) = OpenAiAdapter::from_env() {
160        registry = registry.register(Arc::new(adapter));
161        if registry.default.is_none() {
162            registry = registry.with_default("openai");
163        }
164    }
165
166    if let Ok(adapter) = GoogleAdapter::from_env() {
167        registry = registry.register(Arc::new(adapter));
168        if registry.default.is_none() {
169            registry = registry.with_default("google");
170        }
171    }
172
173    // Ollama is always available if the server is running (no API key needed).
174    // Register it but don't set as default — cloud providers take priority.
175    if let Ok(adapter) = OllamaAdapter::from_env() {
176        registry = registry.register(Arc::new(adapter));
177        if registry.default.is_none() {
178            registry = registry.with_default("ollama");
179        }
180    }
181
182    // Sidecar takes highest priority when configured. In seam mode the registry
183    // is sidecar-only: apply_sidecar discards the native adapters and returns a
184    // fresh registry with no prefix routes and no native fallback. Every model
185    // string routes to the governed Python seam; nothing can bypass it.
186    if let Ok(url) = std::env::var("JAMJET_MODEL_SEAM_URL") {
187        registry = apply_sidecar(registry, url);
188    }
189
190    registry
191}
192
193/// Build a sidecar-only `ModelRegistry` for seam mode.
194///
195/// In seam mode ALL model calls — regardless of model name or prefix — must go
196/// through the governed Python sidecar. We therefore return a FRESH registry
197/// containing ONLY the `SidecarModelAdapter`, with no native adapters and no
198/// prefix routes. Any model string (bare `"claude-sonnet-4-6"`, qualified
199/// `"anthropic/claude-3"`, or empty) falls through to the sidecar default.
200///
201/// The incoming `_registry` (which may contain native adapters built from env
202/// vars) is intentionally discarded — registering native adapters alongside the
203/// sidecar would keep the bypass paths alive.
204///
205/// Extracted so tests can call it directly without touching env vars.
206pub(crate) fn apply_sidecar(_registry: ModelRegistry, url: String) -> ModelRegistry {
207    use crate::sidecar::SidecarModelAdapter;
208    ModelRegistry::new()
209        .register(Arc::new(SidecarModelAdapter::new(url)))
210        .with_default("sidecar")
211}
212
213/// Like [`registry_from_env`] but also probes the sidecar `/health` endpoint.
214///
215/// Returns `Err` if `JAMJET_MODEL_SEAM_URL` is set but the sidecar is
216/// unreachable or responds non-2xx — so a misconfigured deployment fails loud
217/// at startup rather than silently falling through to the native adapters.
218///
219/// Call this at the `main()` call site instead of `registry_from_env()`.
220pub async fn registry_from_env_checked() -> Result<ModelRegistry, ModelError> {
221    let registry = registry_from_env();
222    if let Ok(url) = std::env::var("JAMJET_MODEL_SEAM_URL") {
223        let client = reqwest::Client::new();
224        crate::sidecar::check_sidecar_health(&url, &client).await?;
225    }
226    Ok(registry)
227}
228
229// ── Registry wiring tests ─────────────────────────────────────────────────────
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    /// Serialise env-var-mutating tests to avoid races between parallel test threads.
236    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
237
238    #[test]
239    fn apply_sidecar_sets_sidecar_as_default() {
240        let registry = apply_sidecar(ModelRegistry::new(), "http://127.0.0.1:4280".into());
241        assert_eq!(
242            registry.default_system(),
243            Some("sidecar"),
244            "sidecar must be the default when URL is wired"
245        );
246    }
247
248    #[test]
249    fn apply_sidecar_registers_adapter_by_name() {
250        let registry = apply_sidecar(ModelRegistry::new(), "http://127.0.0.1:4280".into());
251        // The adapter must be reachable (resolve returns Some for empty model name).
252        let adapter = registry.resolve("");
253        assert!(adapter.is_some(), "sidecar adapter must be registered");
254        assert_eq!(adapter.unwrap().system_name(), "sidecar");
255    }
256
257    #[test]
258    fn registry_from_env_sets_sidecar_default_when_url_set() {
259        let _guard = ENV_LOCK.lock().unwrap();
260        // Safety: guarded by ENV_LOCK; removed immediately after.
261        unsafe {
262            std::env::set_var("JAMJET_MODEL_SEAM_URL", "http://127.0.0.1:4280");
263        }
264        let registry = registry_from_env();
265        unsafe {
266            std::env::remove_var("JAMJET_MODEL_SEAM_URL");
267        }
268        assert_eq!(
269            registry.default_system(),
270            Some("sidecar"),
271            "registry_from_env must make sidecar the default when JAMJET_MODEL_SEAM_URL is set"
272        );
273    }
274
275    #[test]
276    fn registry_from_env_no_sidecar_when_url_unset() {
277        let _guard = ENV_LOCK.lock().unwrap();
278        unsafe {
279            std::env::remove_var("JAMJET_MODEL_SEAM_URL");
280        }
281        let registry = registry_from_env();
282        assert_ne!(
283            registry.default_system(),
284            Some("sidecar"),
285            "sidecar must not be default when JAMJET_MODEL_SEAM_URL is absent"
286        );
287    }
288
289    #[tokio::test]
290    async fn registry_from_env_checked_errors_on_unreachable_sidecar() {
291        let _guard = ENV_LOCK.lock().unwrap();
292        unsafe {
293            // Port 1 is never open — connection will be refused immediately.
294            std::env::set_var("JAMJET_MODEL_SEAM_URL", "http://127.0.0.1:1");
295        }
296        let result = registry_from_env_checked().await;
297        unsafe {
298            std::env::remove_var("JAMJET_MODEL_SEAM_URL");
299        }
300        assert!(
301            result.is_err(),
302            "registry_from_env_checked must fail when sidecar is unreachable"
303        );
304    }
305
306    /// C2A: in seam mode ALL model strings must resolve to the sidecar adapter.
307    ///
308    /// This test would FAIL under the old `apply_sidecar` (which kept native
309    /// prefix routes alive — a bare "claude-..." would bypass the sidecar if
310    /// ANTHROPIC_API_KEY was set). It passes after the fix where `apply_sidecar`
311    /// returns a fresh sidecar-only registry.
312    #[test]
313    fn seam_mode_all_model_strings_route_to_sidecar() {
314        let _guard = ENV_LOCK.lock().unwrap();
315        unsafe {
316            std::env::set_var("JAMJET_MODEL_SEAM_URL", "http://127.0.0.1:4280");
317        }
318        let registry = registry_from_env();
319        unsafe {
320            std::env::remove_var("JAMJET_MODEL_SEAM_URL");
321        }
322
323        // Every model string — bare, qualified, empty — must route to sidecar.
324        let cases = [
325            "claude-sonnet-4-6",  // bare string: old bug — routed to native anthropic
326            "anthropic/claude-3", // qualified: also bypassed via prefix route
327            "gpt-4",              // would have routed to native openai
328            "",                   // unspecified default
329        ];
330        for model in &cases {
331            let adapter = registry.resolve(model);
332            assert!(
333                adapter.is_some(),
334                "seam mode: adapter must exist for model string {model:?}"
335            );
336            assert_eq!(
337                adapter.unwrap().system_name(),
338                "sidecar",
339                "seam mode: model string {model:?} must route to sidecar, not a native adapter"
340            );
341        }
342    }
343
344    /// C2A non-seam: prefix routing to native adapters still works without sidecar.
345    ///
346    /// Builds a registry manually (no env vars needed) with a stub adapter and
347    /// verifies that prefix routes function correctly in non-seam mode.
348    #[test]
349    fn non_seam_prefix_routes_work() {
350        use crate::adapter::{
351            ModelAdapter, ModelError, ModelRequest, ModelResponse, StructuredRequest,
352        };
353
354        struct StubAdapter(&'static str);
355        #[async_trait::async_trait]
356        impl ModelAdapter for StubAdapter {
357            fn system_name(&self) -> &'static str {
358                self.0
359            }
360            fn default_model(&self) -> &str {
361                "stub"
362            }
363            async fn chat(&self, _: ModelRequest) -> Result<ModelResponse, ModelError> {
364                unimplemented!()
365            }
366            async fn structured_output(
367                &self,
368                _: StructuredRequest,
369            ) -> Result<ModelResponse, ModelError> {
370                unimplemented!()
371            }
372        }
373
374        let registry = ModelRegistry::new()
375            .route_prefix("anthropic/", "anthropic")
376            .route_prefix("claude-", "anthropic")
377            .route_prefix("gpt-", "openai")
378            .register(Arc::new(StubAdapter("anthropic")))
379            .register(Arc::new(StubAdapter("openai")))
380            .with_default("anthropic");
381
382        // Qualified prefix routes to the right adapter.
383        let a = registry.resolve("anthropic/claude-sonnet-4-6");
384        assert_eq!(a.unwrap().system_name(), "anthropic");
385
386        // Bare model prefix routes correctly.
387        let b = registry.resolve("claude-3-haiku");
388        assert_eq!(b.unwrap().system_name(), "anthropic");
389
390        let c = registry.resolve("gpt-4");
391        assert_eq!(c.unwrap().system_name(), "openai");
392
393        // Unrecognised string falls to the default.
394        let d = registry.resolve("unknown-model");
395        assert_eq!(d.unwrap().system_name(), "anthropic");
396    }
397}