use crate::credentials::{CredentialStore, resolve_provider_api_key};
use crate::registry::RegistryStore;
use crate::{ProviderConfig, resolve_provider_config};
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone)]
pub struct ResolvedBackgroundModel {
pub provider_id: String,
pub model_name: String,
pub provider_config: ProviderConfig,
}
pub struct BackgroundModelResolver {
registry: Option<Arc<RegistryStore>>,
config: Arc<RwLock<crate::config::NaviConfig>>,
credential_store: CredentialStore,
}
impl BackgroundModelResolver {
pub fn new(
registry: Option<Arc<RegistryStore>>,
config: Arc<RwLock<crate::config::NaviConfig>>,
credential_store: CredentialStore,
) -> Self {
Self {
registry,
config,
credential_store,
}
}
pub fn resolve(&self, task: &str) -> ResolvedBackgroundModel {
let config = self.config.read().unwrap_or_else(|e| e.into_inner());
let bg_config = &config.background_models;
if let Some(entry) = bg_config.resolve(task) {
if let (Some(provider), Some(model)) = (&entry.provider, &entry.model)
&& let Some(resolved) = self.try_explicit(provider, model, &config)
{
return resolved;
}
if let Some(profile) = &entry.profile
&& let Some(resolved) = self.resolve_from_profile(profile)
{
return resolved;
}
}
let default_profile = match task {
"naming" => "naming",
"memory_extraction" => "cheap_general",
"repo_search" => "repo_search",
"compaction" => "long_context_cheap",
"subagent_research" => "research_synthesis",
"simple_code_edit" => "cheap_code",
_ => "cheap_general",
};
if let Some(resolved) = self.resolve_from_profile(default_profile) {
return resolved;
}
self.main_model_fallback(&config)
}
fn resolve_from_profile(&self, profile_id: &str) -> Option<ResolvedBackgroundModel> {
let registry = self.registry.as_ref()?;
let ranked = registry.query_models_by_profile(profile_id).ok()?;
for candidate in &ranked {
if self.has_credential(&candidate.provider_id) {
let config = self.config.read().unwrap_or_else(|e| e.into_inner());
if let Some(resolved) =
self.try_explicit(&candidate.provider_id, &candidate.model_name, &config)
{
return Some(resolved);
}
}
}
None
}
fn has_credential(&self, provider_id: &str) -> bool {
let config = self.config.read().unwrap_or_else(|e| e.into_inner());
let Some(provider_config) = resolve_provider_config(&config, provider_id) else {
return false;
};
resolve_provider_api_key(&self.credential_store, &provider_config, provider_id).is_some()
}
fn try_explicit(
&self,
provider_id: &str,
model_name: &str,
config: &crate::config::NaviConfig,
) -> Option<ResolvedBackgroundModel> {
let provider_config = resolve_provider_config(config, provider_id)?;
Some(ResolvedBackgroundModel {
provider_id: provider_id.to_string(),
model_name: model_name.to_string(),
provider_config,
})
}
fn main_model_fallback(&self, config: &crate::config::NaviConfig) -> ResolvedBackgroundModel {
let provider_id = config.model.provider.clone();
let model_name = config.model.name.clone();
let provider_config =
resolve_provider_config(config, &provider_id).unwrap_or_else(|| ProviderConfig {
id: provider_id.clone(),
..ProviderConfig::default()
});
ResolvedBackgroundModel {
provider_id,
model_name,
provider_config,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NaviConfig;
use crate::config::types::{BackgroundModelEntry, ModelConfig};
fn test_resolver(config: NaviConfig) -> BackgroundModelResolver {
let tempdir = tempfile::tempdir().unwrap();
let registry = RegistryStore::open_memory().ok().map(Arc::new);
let config = Arc::new(RwLock::new(config));
let cred_store = CredentialStore::new(tempdir.path().to_path_buf());
BackgroundModelResolver::new(registry, config, cred_store)
}
#[test]
fn resolve_falls_back_to_main_model() {
let config = NaviConfig {
model: ModelConfig {
provider: "openai".to_string(),
name: "gpt-5.5".to_string(),
},
..Default::default()
};
let resolver = test_resolver(config);
let resolved = resolver.resolve("naming");
assert_eq!(resolved.provider_id, "openai");
assert_eq!(resolved.model_name, "gpt-5.5");
}
#[test]
fn resolve_explicit_override() {
let mut config = NaviConfig {
model: ModelConfig {
provider: "openai".to_string(),
name: "gpt-5.5".to_string(),
},
..Default::default()
};
config.background_models.naming = Some(BackgroundModelEntry {
profile: None,
provider: Some("anthropic".to_string()),
model: Some("claude-haiku".to_string()),
fallback: None,
});
config.providers.push(crate::ProviderConfig {
id: "anthropic".to_string(),
label: "Anthropic".to_string(),
kind: crate::ProviderKind::AnthropicMessages,
api_key_env: "ANTHROPIC_API_KEY".to_string(),
..Default::default()
});
let resolver = test_resolver(config);
let resolved = resolver.resolve("naming");
assert_eq!(resolved.provider_id, "anthropic");
assert_eq!(resolved.model_name, "claude-haiku");
}
#[test]
fn task_to_default_profile_mapping() {
let config = NaviConfig::default();
let resolver = test_resolver(config);
for task in &["naming", "repo_search", "compaction", "subagent_research"] {
let resolved = resolver.resolve(task);
assert_eq!(resolved.provider_id, "openai");
}
}
}