1use crate::adapter::{ModelAdapter, ModelError, ModelRequest, ModelResponse, StructuredRequest};
7use std::collections::HashMap;
8use std::sync::Arc;
9
10pub struct ModelRegistry {
16 adapters: HashMap<String, Arc<dyn ModelAdapter>>,
17 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 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 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 pub fn with_default(mut self, system: impl Into<String>) -> Self {
46 self.default = Some(system.into());
47 self
48 }
49
50 fn resolve(&self, model: &str) -> Option<Arc<dyn ModelAdapter>> {
52 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 if let Some(default) = &self.default {
62 return self.adapters.get(default).map(Arc::clone);
63 }
64 if self.adapters.len() == 1 {
66 return self.adapters.values().next().map(Arc::clone);
67 }
68 None
69 }
70
71 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 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 pub fn default_system(&self) -> Option<&str> {
104 self.default.as_deref()
105 }
106}
107
108pub 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 .route_prefix("anthropic/", "anthropic")
136 .route_prefix("openai/", "openai")
137 .route_prefix("google/", "google")
138 .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 .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 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 if let Ok(url) = std::env::var("JAMJET_MODEL_SEAM_URL") {
187 registry = apply_sidecar(registry, url);
188 }
189
190 registry
191}
192
193pub(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
213pub 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#[cfg(test)]
232mod tests {
233 use super::*;
234
235 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 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 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 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 #[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 let cases = [
325 "claude-sonnet-4-6", "anthropic/claude-3", "gpt-4", "", ];
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 #[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 let a = registry.resolve("anthropic/claude-sonnet-4-6");
384 assert_eq!(a.unwrap().system_name(), "anthropic");
385
386 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 let d = registry.resolve("unknown-model");
395 assert_eq!(d.unwrap().system_name(), "anthropic");
396 }
397}