use std::sync::Arc;
use bamboo_domain::reasoning::ReasoningEffort;
use bamboo_domain::ProviderModelRef;
use bamboo_llm::{Config, ProviderRegistry, ResolvedModel};
use crate::model_config_helper::{
resolve_background_model, resolve_fast_model, resolve_fast_model_for_runner,
resolve_planning_model, resolve_search_model, resolve_subagent_model, resolve_vision_model,
try_resolve_task_summary_model,
};
#[cfg(test)]
#[path = "model_areas_live_tests.rs"]
mod live_tests;
pub struct GlobalAreaModels {
pub fast: Option<ResolvedModel>,
pub fast_ref: Option<ProviderModelRef>,
pub background: Option<ResolvedModel>,
pub background_ref: Option<ProviderModelRef>,
pub summarization: Option<ResolvedModel>,
pub summarization_ref: Option<ProviderModelRef>,
pub summarization_error: Option<String>,
}
pub fn resolve_global_area_models(
config: &Config,
provider_name: &str,
provider_registry: &Arc<ProviderRegistry>,
) -> GlobalAreaModels {
let refs = config.defaults.as_ref().map(|defaults| {
let fast = defaults.fast.as_ref().unwrap_or(&defaults.chat);
(
fast.clone(),
defaults.memory_background.as_ref().unwrap_or(fast).clone(),
)
});
let (summarization, summarization_error) =
match try_resolve_task_summary_model(config, provider_name, provider_registry) {
Ok(model) => (model, None),
Err(error) => {
tracing::warn!(%error, "Configured task-summary route is unavailable");
(None, Some(error.to_string()))
}
};
GlobalAreaModels {
fast: resolve_fast_model(config, provider_name, provider_registry),
fast_ref: refs.as_ref().map(|(fast, _)| fast.clone()),
background: resolve_background_model(config, provider_name, provider_registry),
background_ref: refs.map(|(_, background)| background),
summarization,
summarization_ref: config.defaults.as_ref().map(|defaults| {
defaults
.task_summary
.as_ref()
.or(defaults.fast.as_ref())
.unwrap_or(&defaults.chat)
.clone()
}),
summarization_error,
}
}
#[doc(hidden)]
pub fn live_background_model_resolver(
config: Arc<tokio::sync::RwLock<Config>>,
registry: Arc<ProviderRegistry>,
provider_name: String,
fixed: crate::AuxiliaryModelConfig,
) -> Arc<dyn Fn() -> crate::AuxiliaryModelConfig + Send + Sync> {
Arc::new(move || {
let background = if provider_name.is_empty() {
None
} else {
config
.try_read()
.ok()
.and_then(|current| resolve_background_model(¤t, &provider_name, ®istry))
};
let mut models = fixed.clone();
models.background_model_name = background.as_ref().map(|model| model.model_name.clone());
models.background_model_provider = background.map(|model| model.provider);
models
})
}
#[doc(hidden)]
pub fn live_resume_auxiliary_resolver(
config: Arc<tokio::sync::RwLock<Config>>,
registry: Arc<ProviderRegistry>,
provider_name: String,
) -> crate::runtime::config::ResumeAuxiliaryResolver {
Arc::new(move || {
if provider_name.is_empty() {
return (
Err(bamboo_llm::LLMError::Api(
"Connect resume provider is unavailable".into(),
)),
None,
);
}
let Ok(current) = config.try_read() else {
return (
Err(bamboo_llm::LLMError::Api(
"Live model configuration is unavailable for Connect resume".into(),
)),
None,
);
};
let summary = try_resolve_task_summary_model(¤t, &provider_name, ®istry);
let background = resolve_background_model(¤t, &provider_name, ®istry);
(summary, background)
})
}
#[doc(hidden)]
pub fn live_auxiliary_model_resolver(
config: Arc<tokio::sync::RwLock<Config>>,
registry: Arc<ProviderRegistry>,
provider_name: String,
) -> Arc<dyn Fn() -> crate::AuxiliaryModelConfig + Send + Sync> {
Arc::new(move || {
let current = if provider_name.trim().is_empty() {
None
} else {
config.try_read().ok()
};
let Some(current) = current else {
let (fast_model_name, fast_model_provider) =
resolve_fast_model_for_runner(None, &provider_name, ®istry);
return crate::AuxiliaryModelConfig {
fast_model_name,
fast_model_provider,
summarization_model_error: Some(
"Live model configuration or provider key is unavailable".into(),
),
..Default::default()
};
};
let (fast_model_name, fast_model_provider) =
resolve_fast_model_for_runner(Some(¤t), &provider_name, ®istry);
let background = resolve_background_model(¤t, &provider_name, ®istry);
let planning = resolve_planning_model(¤t, &provider_name, ®istry);
let search = resolve_search_model(¤t, &provider_name, ®istry);
let (summary, summarization_model_error) =
match try_resolve_task_summary_model(¤t, &provider_name, ®istry) {
Ok(summary) => (summary, None),
Err(error) => (None, Some(error.to_string())),
};
crate::AuxiliaryModelConfig {
fast_model_name,
fast_model_provider,
background_model_name: background.as_ref().map(|model| model.model_name.clone()),
background_model_provider: background.map(|model| model.provider),
planning_model_name: planning.map(|model| model.model_name),
search_model_name: search.map(|model| model.model_name),
summarization_model_name: summary.as_ref().map(|model| model.model_name.clone()),
summarization_model_provider: summary.map(|model| model.provider),
summarization_model_error,
}
})
}
pub fn resolve_global_vision_model(
config: &Config,
provider_name: &str,
provider_registry: &Arc<ProviderRegistry>,
) -> Option<ResolvedModel> {
resolve_vision_model(config, provider_name, provider_registry)
}
pub fn resolve_global_subagent_model(
config: &Config,
provider_name: &str,
provider_registry: &Arc<ProviderRegistry>,
subagent_type: &str,
) -> Option<ResolvedModel> {
resolve_subagent_model(config, provider_name, provider_registry, subagent_type)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffortSource {
Session,
Request,
ModelDefault,
ProviderDefault,
None,
}
impl ReasoningEffortSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Session => "session",
Self::Request => "request",
Self::ModelDefault => "model_default",
Self::ProviderDefault => "provider_default",
Self::None => "none",
}
}
}
pub fn resolve_effective_reasoning_effort(
session_effort: Option<ReasoningEffort>,
request_effort: Option<ReasoningEffort>,
model_default: Option<ReasoningEffort>,
provider_default: Option<ReasoningEffort>,
) -> (Option<ReasoningEffort>, ReasoningEffortSource) {
if let Some(effort) = session_effort {
(Some(effort), ReasoningEffortSource::Session)
} else if let Some(effort) = request_effort {
(Some(effort), ReasoningEffortSource::Request)
} else if let Some(effort) = model_default {
(Some(effort), ReasoningEffortSource::ModelDefault)
} else if let Some(effort) = provider_default {
(Some(effort), ReasoningEffortSource::ProviderDefault)
} else {
(None, ReasoningEffortSource::None)
}
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! test_config {
(@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
(@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
(@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
(@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
($($field:ident: $value:expr),* $(,)?) => {{
let mut config = Config::default();
$(test_config!(@assign config, $field, $value);)*
config
}};
}
use bamboo_agent_core::tools::ToolSchema;
use bamboo_agent_core::Message;
use bamboo_config::{DefaultsConfig, FeatureFlags};
use bamboo_config::{OpenAIConfig, ProviderConfigs};
use bamboo_domain::{Session, DEFAULT_REASONING_EFFORT};
use bamboo_llm::{LLMError, LLMProvider, LLMStream};
use std::collections::HashMap;
struct NoopProvider;
#[async_trait::async_trait]
impl LLMProvider for NoopProvider {
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
) -> Result<LLMStream, LLMError> {
Err(LLMError::Api("noop".to_string()))
}
}
fn test_registry() -> Arc<ProviderRegistry> {
let mut providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::new();
providers.insert("openai".to_string(), Arc::new(NoopProvider));
Arc::new(ProviderRegistry::new(providers, "openai".to_string()))
}
fn defaults_with_all_areas() -> DefaultsConfig {
DefaultsConfig {
chat: ProviderModelRef::new("openai", "gpt-chat"),
fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
task_summary: Some(ProviderModelRef::new("openai", "gpt-summary")),
vision: Some(ProviderModelRef::new("openai", "gpt-vision")),
memory_background: Some(ProviderModelRef::new("openai", "gpt-memory")),
planning: None,
search: None,
code_review: None,
sub_agent: Some(ProviderModelRef::new("openai", "gpt-sub")),
subagent_models: HashMap::new(),
}
}
fn config_with_defaults(defaults: DefaultsConfig) -> Config {
test_config! {
provider: "openai".to_string(),
features: FeatureFlags {
provider_model_ref: true,
..Default::default()
},
defaults: Some(defaults),
}
}
#[test]
fn global_area_models_read_each_area_from_its_own_default() {
let config = config_with_defaults(defaults_with_all_areas());
let areas = resolve_global_area_models(&config, "openai", &test_registry());
assert_eq!(
areas.fast.as_ref().map(|m| m.model_name.as_str()),
Some("gpt-fast")
);
assert_eq!(
areas.summarization.as_ref().map(|m| m.model_name.as_str()),
Some("gpt-summary")
);
assert_eq!(
areas.background.as_ref().map(|m| m.model_name.as_str()),
Some("gpt-memory")
);
assert_eq!(
areas.fast_ref,
Some(ProviderModelRef::new("openai", "gpt-fast"))
);
assert_eq!(
areas.summarization_ref,
Some(ProviderModelRef::new("openai", "gpt-summary"))
);
assert_eq!(
areas.background_ref,
Some(ProviderModelRef::new("openai", "gpt-memory"))
);
}
#[test]
fn global_area_models_are_independent_of_any_session() {
let config = config_with_defaults(defaults_with_all_areas());
let registry = test_registry();
let before = resolve_global_area_models(&config, "openai", ®istry);
let mut session = Session::new("s1", "some-exotic-session-model");
session.model_ref = Some(ProviderModelRef::new("openai", "some-exotic-session-model"));
session.reasoning_effort = Some(ReasoningEffort::Max);
let _ = &session;
let after = resolve_global_area_models(&config, "openai", ®istry);
assert_eq!(
before.fast.as_ref().map(|m| m.model_name.clone()),
after.fast.as_ref().map(|m| m.model_name.clone())
);
assert_eq!(
before.background.as_ref().map(|m| m.model_name.clone()),
after.background.as_ref().map(|m| m.model_name.clone())
);
assert_eq!(
before.summarization.as_ref().map(|m| m.model_name.clone()),
after.summarization.as_ref().map(|m| m.model_name.clone())
);
assert_ne!(
after.fast.as_ref().map(|m| m.model_name.as_str()),
Some("some-exotic-session-model")
);
}
#[test]
fn vision_model_is_global_from_defaults() {
let config = config_with_defaults(defaults_with_all_areas());
let vision = resolve_global_vision_model(&config, "openai", &test_registry());
assert_eq!(
vision.as_ref().map(|m| m.model_name.as_str()),
Some("gpt-vision")
);
}
#[test]
fn subagent_model_is_global_from_defaults() {
let config = config_with_defaults(defaults_with_all_areas());
let sub = resolve_global_subagent_model(&config, "openai", &test_registry(), "coder");
assert_eq!(sub.as_ref().map(|m| m.model_name.as_str()), Some("gpt-sub"));
}
#[test]
fn background_falls_back_to_fast_when_memory_background_unset() {
let mut defaults = defaults_with_all_areas();
defaults.memory_background = None;
let config = config_with_defaults(defaults);
let areas = resolve_global_area_models(&config, "openai", &test_registry());
assert_eq!(
areas.background.as_ref().map(|m| m.model_name.as_str()),
Some("gpt-fast")
);
assert_eq!(
areas.background_ref,
Some(ProviderModelRef::new("openai", "gpt-fast"))
);
}
#[test]
fn global_area_refs_keep_the_fallback_models_reasoning_effort() {
let mut defaults = defaults_with_all_areas();
defaults.chat = defaults.chat.with_reasoning_effort(ReasoningEffort::Max);
defaults.fast = None;
defaults.memory_background = None;
defaults.task_summary = None;
let expected = defaults.chat.clone();
let config = config_with_defaults(defaults);
let areas = resolve_global_area_models(&config, "openai", &test_registry());
assert_eq!(areas.fast_ref, Some(expected.clone()));
assert_eq!(areas.background_ref, Some(expected.clone()));
assert_eq!(areas.summarization_ref, Some(expected));
}
#[test]
fn legacy_mode_resolves_fast_from_provider_config() {
let config = test_config! {
provider: "openai".to_string(),
features: FeatureFlags {
provider_model_ref: false,
..Default::default()
},
defaults: None,
providers: ProviderConfigs {
openai: Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_from_env: false,
api_key_encrypted: None,
credential_ref: None,
base_url: None,
model: Some("gpt-4o".to_string()),
fast_model: Some("gpt-4o-mini".to_string()),
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
}),
..ProviderConfigs::default()
},
};
let areas = resolve_global_area_models(&config, "openai", &test_registry());
assert_eq!(
areas.fast.as_ref().map(|m| m.model_name.as_str()),
Some("gpt-4o-mini")
);
}
#[test]
fn reasoning_prefers_session_then_request_then_model_then_provider() {
assert_eq!(
resolve_effective_reasoning_effort(
Some(ReasoningEffort::Max),
Some(ReasoningEffort::High),
Some(ReasoningEffort::Low),
Some(ReasoningEffort::Medium),
),
(Some(ReasoningEffort::Max), ReasoningEffortSource::Session)
);
assert_eq!(
resolve_effective_reasoning_effort(
None,
Some(ReasoningEffort::High),
Some(ReasoningEffort::Low),
Some(ReasoningEffort::Medium),
),
(Some(ReasoningEffort::High), ReasoningEffortSource::Request)
);
assert_eq!(
resolve_effective_reasoning_effort(
None,
None,
Some(ReasoningEffort::Low),
Some(ReasoningEffort::Medium),
),
(
Some(ReasoningEffort::Low),
ReasoningEffortSource::ModelDefault
)
);
assert_eq!(
resolve_effective_reasoning_effort(None, None, None, Some(ReasoningEffort::Low),),
(
Some(ReasoningEffort::Low),
ReasoningEffortSource::ProviderDefault
)
);
}
#[test]
fn reasoning_none_when_nothing_configured() {
let (effort, source) = resolve_effective_reasoning_effort(None, None, None, None);
assert_eq!(effort, None);
assert_eq!(source, ReasoningEffortSource::None);
}
#[test]
fn canonical_default_is_medium_and_used_as_terminal() {
assert_eq!(DEFAULT_REASONING_EFFORT, ReasoningEffort::Medium);
let (effort, _) = resolve_effective_reasoning_effort(None, None, None, None);
assert_eq!(
effort.unwrap_or(DEFAULT_REASONING_EFFORT),
ReasoningEffort::Medium
);
}
}