use crate::adapter::{ModelAdapter, ModelError, ModelRequest, ModelResponse, StructuredRequest};
use std::collections::HashMap;
use std::sync::Arc;
pub struct ModelRegistry {
adapters: HashMap<String, Arc<dyn ModelAdapter>>,
prefix_routes: Vec<(String, String)>,
default: Option<String>,
}
impl ModelRegistry {
pub fn new() -> Self {
Self {
adapters: HashMap::new(),
prefix_routes: Vec::new(),
default: None,
}
}
pub fn register(mut self, adapter: Arc<dyn ModelAdapter>) -> Self {
let name = adapter.system_name().to_string();
self.adapters.insert(name, adapter);
self
}
pub fn route_prefix(mut self, prefix: impl Into<String>, system: impl Into<String>) -> Self {
self.prefix_routes.push((prefix.into(), system.into()));
self
}
pub fn with_default(mut self, system: impl Into<String>) -> Self {
self.default = Some(system.into());
self
}
fn resolve(&self, model: &str) -> Option<Arc<dyn ModelAdapter>> {
for (prefix, system) in &self.prefix_routes {
if model.starts_with(prefix.as_str()) {
if let Some(adapter) = self.adapters.get(system) {
return Some(Arc::clone(adapter));
}
}
}
if let Some(default) = &self.default {
return self.adapters.get(default).map(Arc::clone);
}
if self.adapters.len() == 1 {
return self.adapters.values().next().map(Arc::clone);
}
None
}
pub async fn chat(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
let model = request.config.model.clone().unwrap_or_default();
let adapter = self
.resolve(&model)
.ok_or_else(|| ModelError::Network(format!("no adapter for model: {model}")))?;
adapter.chat(request).await
}
pub async fn structured_output(
&self,
request: StructuredRequest,
) -> Result<ModelResponse, ModelError> {
let model = request.config.model.clone().unwrap_or_default();
let adapter = self
.resolve(&model)
.ok_or_else(|| ModelError::Network(format!("no adapter for model: {model}")))?;
adapter.structured_output(request).await
}
}
impl Default for ModelRegistry {
fn default() -> Self {
Self::new()
}
}
impl ModelRegistry {
pub fn default_system(&self) -> Option<&str> {
self.default.as_deref()
}
}
pub fn registry_from_env() -> ModelRegistry {
use crate::{
anthropic::AnthropicAdapter, google::GoogleAdapter, ollama::OllamaAdapter,
openai::OpenAiAdapter,
};
let mut registry = ModelRegistry::new()
.route_prefix("anthropic/", "anthropic")
.route_prefix("openai/", "openai")
.route_prefix("google/", "google")
.route_prefix("claude-", "anthropic")
.route_prefix("gpt-", "openai")
.route_prefix("o1-", "openai")
.route_prefix("o3-", "openai")
.route_prefix("gemini-", "google")
.route_prefix("llama", "ollama")
.route_prefix("qwen", "ollama")
.route_prefix("gemma", "ollama")
.route_prefix("phi", "ollama")
.route_prefix("mistral", "ollama")
.route_prefix("codellama", "ollama")
.route_prefix("deepseek", "ollama")
.route_prefix("nomic-", "ollama");
if let Ok(adapter) = AnthropicAdapter::from_env() {
registry = registry.register(Arc::new(adapter));
registry = registry.with_default("anthropic");
}
if let Ok(adapter) = OpenAiAdapter::from_env() {
registry = registry.register(Arc::new(adapter));
if registry.default.is_none() {
registry = registry.with_default("openai");
}
}
if let Ok(adapter) = GoogleAdapter::from_env() {
registry = registry.register(Arc::new(adapter));
if registry.default.is_none() {
registry = registry.with_default("google");
}
}
if let Ok(adapter) = OllamaAdapter::from_env() {
registry = registry.register(Arc::new(adapter));
if registry.default.is_none() {
registry = registry.with_default("ollama");
}
}
if let Ok(url) = std::env::var("JAMJET_MODEL_SEAM_URL") {
registry = apply_sidecar(registry, url);
}
registry
}
pub(crate) fn apply_sidecar(_registry: ModelRegistry, url: String) -> ModelRegistry {
use crate::sidecar::SidecarModelAdapter;
ModelRegistry::new()
.register(Arc::new(SidecarModelAdapter::new(url)))
.with_default("sidecar")
}
pub async fn registry_from_env_checked() -> Result<ModelRegistry, ModelError> {
let registry = registry_from_env();
if let Ok(url) = std::env::var("JAMJET_MODEL_SEAM_URL") {
let client = reqwest::Client::new();
crate::sidecar::check_sidecar_health(&url, &client).await?;
}
Ok(registry)
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn apply_sidecar_sets_sidecar_as_default() {
let registry = apply_sidecar(ModelRegistry::new(), "http://127.0.0.1:4280".into());
assert_eq!(
registry.default_system(),
Some("sidecar"),
"sidecar must be the default when URL is wired"
);
}
#[test]
fn apply_sidecar_registers_adapter_by_name() {
let registry = apply_sidecar(ModelRegistry::new(), "http://127.0.0.1:4280".into());
let adapter = registry.resolve("");
assert!(adapter.is_some(), "sidecar adapter must be registered");
assert_eq!(adapter.unwrap().system_name(), "sidecar");
}
#[test]
fn registry_from_env_sets_sidecar_default_when_url_set() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("JAMJET_MODEL_SEAM_URL", "http://127.0.0.1:4280");
}
let registry = registry_from_env();
unsafe {
std::env::remove_var("JAMJET_MODEL_SEAM_URL");
}
assert_eq!(
registry.default_system(),
Some("sidecar"),
"registry_from_env must make sidecar the default when JAMJET_MODEL_SEAM_URL is set"
);
}
#[test]
fn registry_from_env_no_sidecar_when_url_unset() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::remove_var("JAMJET_MODEL_SEAM_URL");
}
let registry = registry_from_env();
assert_ne!(
registry.default_system(),
Some("sidecar"),
"sidecar must not be default when JAMJET_MODEL_SEAM_URL is absent"
);
}
#[tokio::test]
async fn registry_from_env_checked_errors_on_unreachable_sidecar() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("JAMJET_MODEL_SEAM_URL", "http://127.0.0.1:1");
}
let result = registry_from_env_checked().await;
unsafe {
std::env::remove_var("JAMJET_MODEL_SEAM_URL");
}
assert!(
result.is_err(),
"registry_from_env_checked must fail when sidecar is unreachable"
);
}
#[test]
fn seam_mode_all_model_strings_route_to_sidecar() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("JAMJET_MODEL_SEAM_URL", "http://127.0.0.1:4280");
}
let registry = registry_from_env();
unsafe {
std::env::remove_var("JAMJET_MODEL_SEAM_URL");
}
let cases = [
"claude-sonnet-4-6", "anthropic/claude-3", "gpt-4", "", ];
for model in &cases {
let adapter = registry.resolve(model);
assert!(
adapter.is_some(),
"seam mode: adapter must exist for model string {model:?}"
);
assert_eq!(
adapter.unwrap().system_name(),
"sidecar",
"seam mode: model string {model:?} must route to sidecar, not a native adapter"
);
}
}
#[test]
fn non_seam_prefix_routes_work() {
use crate::adapter::{
ModelAdapter, ModelError, ModelRequest, ModelResponse, StructuredRequest,
};
struct StubAdapter(&'static str);
#[async_trait::async_trait]
impl ModelAdapter for StubAdapter {
fn system_name(&self) -> &'static str {
self.0
}
fn default_model(&self) -> &str {
"stub"
}
async fn chat(&self, _: ModelRequest) -> Result<ModelResponse, ModelError> {
unimplemented!()
}
async fn structured_output(
&self,
_: StructuredRequest,
) -> Result<ModelResponse, ModelError> {
unimplemented!()
}
}
let registry = ModelRegistry::new()
.route_prefix("anthropic/", "anthropic")
.route_prefix("claude-", "anthropic")
.route_prefix("gpt-", "openai")
.register(Arc::new(StubAdapter("anthropic")))
.register(Arc::new(StubAdapter("openai")))
.with_default("anthropic");
let a = registry.resolve("anthropic/claude-sonnet-4-6");
assert_eq!(a.unwrap().system_name(), "anthropic");
let b = registry.resolve("claude-3-haiku");
assert_eq!(b.unwrap().system_name(), "anthropic");
let c = registry.resolve("gpt-4");
assert_eq!(c.unwrap().system_name(), "openai");
let d = registry.resolve("unknown-model");
assert_eq!(d.unwrap().system_name(), "anthropic");
}
}