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
99/// Build a `ModelRegistry` from environment variables.
100///
101/// Registers adapters based on available API keys / services:
102/// - Anthropic if `ANTHROPIC_API_KEY` is set
103/// - OpenAI if `OPENAI_API_KEY` is set
104/// - Google if `GOOGLE_API_KEY` or `GEMINI_API_KEY` is set
105/// - Ollama if `OLLAMA_HOST` is set or defaults to localhost:11434
106///
107/// Sets up standard prefix routing:
108///   claude-* → anthropic, gpt-*/o1-*/o3-* → openai,
109///   gemini-* → google, ollama model names → ollama.
110pub fn registry_from_env() -> ModelRegistry {
111    use crate::{
112        anthropic::AnthropicAdapter, google::GoogleAdapter, ollama::OllamaAdapter,
113        openai::OpenAiAdapter,
114    };
115
116    let mut registry = ModelRegistry::new()
117        .route_prefix("claude-", "anthropic")
118        .route_prefix("gpt-", "openai")
119        .route_prefix("o1-", "openai")
120        .route_prefix("o3-", "openai")
121        .route_prefix("gemini-", "google")
122        .route_prefix("google/", "google")
123        // Common Ollama model name patterns.
124        .route_prefix("llama", "ollama")
125        .route_prefix("qwen", "ollama")
126        .route_prefix("gemma", "ollama")
127        .route_prefix("phi", "ollama")
128        .route_prefix("mistral", "ollama")
129        .route_prefix("codellama", "ollama")
130        .route_prefix("deepseek", "ollama")
131        .route_prefix("nomic-", "ollama");
132
133    if let Ok(adapter) = AnthropicAdapter::from_env() {
134        registry = registry.register(Arc::new(adapter));
135        registry = registry.with_default("anthropic");
136    }
137
138    if let Ok(adapter) = OpenAiAdapter::from_env() {
139        registry = registry.register(Arc::new(adapter));
140        if registry.default.is_none() {
141            registry = registry.with_default("openai");
142        }
143    }
144
145    if let Ok(adapter) = GoogleAdapter::from_env() {
146        registry = registry.register(Arc::new(adapter));
147        if registry.default.is_none() {
148            registry = registry.with_default("google");
149        }
150    }
151
152    // Ollama is always available if the server is running (no API key needed).
153    // Register it but don't set as default — cloud providers take priority.
154    if let Ok(adapter) = OllamaAdapter::from_env() {
155        registry = registry.register(Arc::new(adapter));
156        if registry.default.is_none() {
157            registry = registry.with_default("ollama");
158        }
159    }
160
161    registry
162}