use std::collections::{BTreeSet, HashMap};
use crate::{Config, DefaultsConfig, ProviderInstanceConfig};
pub const RUNTIME_MODELS_CONFIG_KEY: &str = "runtime_models";
pub fn explicit_runtime_models(
instance: &ProviderInstanceConfig,
) -> Result<Option<Vec<String>>, String> {
let Some(value) = instance.extra.get(RUNTIME_MODELS_CONFIG_KEY) else {
return Ok(None);
};
let values = value
.as_array()
.ok_or_else(|| "runtime_models must be an array of non-empty model IDs".to_string())?;
let mut models = BTreeSet::new();
for value in values {
let model = value
.as_str()
.map(str::trim)
.filter(|model| !model.is_empty())
.ok_or_else(|| "runtime_models must contain only non-empty model IDs".to_string())?;
models.insert(model.to_string());
}
Ok(Some(models.into_iter().collect()))
}
fn default_refs(
defaults: &DefaultsConfig,
) -> impl Iterator<Item = &bamboo_domain::ProviderModelRef> {
std::iter::once(&defaults.chat)
.chain(defaults.fast.iter())
.chain(defaults.task_summary.iter())
.chain(defaults.vision.iter())
.chain(defaults.memory_background.iter())
.chain(defaults.planning.iter())
.chain(defaults.search.iter())
.chain(defaults.code_review.iter())
.chain(defaults.sub_agent.iter())
.chain(defaults.subagent_models.values())
}
pub fn configured_provider_routing_key(config: &Config, provider: &str) -> String {
let provider = provider.trim();
if config.provider_instances.contains_key(provider)
|| ((!config.has_provider_instances() || config.effective_default_provider() == provider)
&& crate::synthesize_legacy_instances(config)
.iter()
.any(|(id, _)| id == provider))
{
return provider.to_string();
}
if let Some(id) = config.default_provider_instance.as_deref() {
if config
.provider_instances
.get(id)
.is_some_and(|instance| instance.provider_type == provider)
{
return id.to_string();
}
}
config
.provider_instances
.iter()
.filter(|(_, instance)| instance.enabled && instance.provider_type == provider)
.map(|(id, _)| id)
.min()
.cloned()
.unwrap_or_else(|| provider.to_string())
}
fn configured_models(config: &Config, id: &str, instance: &ProviderInstanceConfig) -> Vec<String> {
let mut models: BTreeSet<String> = [
instance.model.as_deref(),
instance.fast_model.as_deref(),
instance.vision_model.as_deref(),
]
.into_iter()
.flatten()
.map(str::trim)
.filter(|model| !model.is_empty())
.map(ToString::to_string)
.collect();
if let Some(defaults) = config.defaults.as_ref() {
for reference in default_refs(defaults) {
if configured_provider_routing_key(config, &reference.provider) == id
&& !reference.model.trim().is_empty()
{
models.insert(reference.model.trim().to_string());
}
}
}
models.into_iter().collect()
}
pub fn provider_runtime_models(config: &Config, provider: &str) -> Result<Vec<String>, String> {
let id = configured_provider_routing_key(config, provider);
let synthesized = crate::synthesize_legacy_instances(config);
let instance = config.provider_instances.get(&id).or_else(|| {
synthesized
.iter()
.find(|(name, _)| name == &id)
.map(|(_, instance)| instance)
});
let Some(instance) = instance else {
return Ok(Vec::new());
};
if !instance.enabled {
return Ok(Vec::new());
}
Ok(explicit_runtime_models(instance)?
.unwrap_or_else(|| configured_models(config, &id, instance)))
}
pub fn validate_runtime_model_admission(config: &Config) -> Result<(), String> {
let mut instances: HashMap<String, ProviderInstanceConfig> =
crate::synthesize_legacy_instances(config)
.into_iter()
.collect();
instances.extend(config.provider_instances.clone());
for (id, instance) in &instances {
crate::model_vision_overrides(instance)?;
if let Some(admitted) = explicit_runtime_models(instance)? {
for model in configured_models(config, id, instance) {
if !admitted.contains(&model) {
return Err(format!(
"Model '{model}' is configured for provider '{id}' but is not in runtime_models; change its role assignment before removing it"
));
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn config() -> Config {
let mut config = Config::default();
config.provider_instances.insert("relay".into(), serde_json::from_value(json!({
"provider_type":"openai", "model":"chat", "fast_model":"fast", "vision_model":"vision"
})).unwrap());
config.defaults = Some(
serde_json::from_value(json!({
"chat":{"provider":"relay","model":"chat"},
"sub_agent":{"provider":"relay","model":"child"},
"fast":{"provider":"other","model":"other-fast"}
}))
.unwrap(),
);
config
}
#[test]
fn missing_admission_seeds_only_configured_models_for_that_provider() {
assert_eq!(
provider_runtime_models(&config(), "relay").unwrap(),
["chat", "child", "fast", "vision"]
);
}
#[test]
fn explicit_custom_ids_are_trimmed_and_deduplicated_without_discovery() {
let mut config = config();
config
.provider_instances
.get_mut("relay")
.unwrap()
.extra
.insert(
RUNTIME_MODELS_CONFIG_KEY.into(),
json!([" custom/id ", "custom/id"]),
);
assert_eq!(
provider_runtime_models(&config, "relay").unwrap(),
["custom/id"]
);
assert!(validate_runtime_model_admission(&config).is_err());
}
#[test]
fn explicit_empty_is_not_repopulated_from_defaults() {
let mut config = config();
config
.provider_instances
.get_mut("relay")
.unwrap()
.extra
.insert(RUNTIME_MODELS_CONFIG_KEY.into(), json!([]));
assert!(provider_runtime_models(&config, "relay")
.unwrap()
.is_empty());
assert!(validate_runtime_model_admission(&config)
.unwrap_err()
.contains("role assignment"));
config.defaults = None;
let instance = config.provider_instances.get_mut("relay").unwrap();
instance.model = None;
instance.fast_model = None;
instance.vision_model = None;
assert!(validate_runtime_model_admission(&config).is_ok());
}
#[test]
fn malformed_admission_is_never_treated_as_missing() {
for value in [json!(null), json!("all"), json!([42]), json!([""])] {
let mut config = config();
config
.provider_instances
.get_mut("relay")
.unwrap()
.extra
.insert(RUNTIME_MODELS_CONFIG_KEY.into(), value);
assert!(provider_runtime_models(&config, "relay").is_err());
assert!(validate_runtime_model_admission(&config).is_err());
}
}
#[test]
fn explicit_custom_admission_survives_save_and_cold_load() {
let temp = tempfile::tempdir().unwrap();
let mut config = config();
config.defaults = None;
let instance = config.provider_instances.get_mut("relay").unwrap();
instance.model = Some("custom/id".into());
instance.fast_model = None;
instance.vision_model = None;
instance
.extra
.insert(RUNTIME_MODELS_CONFIG_KEY.into(), json!(["custom/id"]));
config.default_provider_instance = Some("relay".into());
config.save_to_dir(temp.path().to_path_buf()).unwrap();
let loaded = Config::from_data_dir_without_publish(Some(temp.path().to_path_buf()));
assert_eq!(
provider_runtime_models(&loaded, "relay").unwrap(),
["custom/id"]
);
assert_eq!(
explicit_runtime_models(&loaded.provider_instances["relay"]).unwrap(),
Some(vec!["custom/id".into()])
);
}
#[test]
fn full_config_save_rejects_unadmitted_default_without_mutating_disk() {
let temp = tempfile::tempdir().unwrap();
let mut config = config();
config.save_to_dir(temp.path().to_path_buf()).unwrap();
let before = std::fs::read(temp.path().join("config.json")).unwrap();
config
.provider_instances
.get_mut("relay")
.unwrap()
.extra
.insert(RUNTIME_MODELS_CONFIG_KEY.into(), json!([]));
assert!(config.save_to_dir(temp.path().to_path_buf()).is_err());
assert_eq!(
std::fs::read(temp.path().join("config.json")).unwrap(),
before
);
}
}