1use std::sync::Arc;
21
22use bamboo_domain::reasoning::ReasoningEffort;
23use bamboo_domain::ProviderModelRef;
24use bamboo_llm::{Config, LLMProvider, ProviderRegistry};
25
26use crate::config::GoldConfig;
27use crate::ModelRoster;
28
29#[derive(Clone)]
32pub struct ResolvedDefaultRunConfig {
33 pub model_roster: ModelRoster,
37 pub model_provider: Option<Arc<dyn LLMProvider>>,
40 pub model_origin: Option<Arc<bamboo_llm::provider_registry::ManagedProviderOrigin>>,
41 pub model_error: Option<String>,
42 pub reasoning_effort: Option<ReasoningEffort>,
43 pub gold_config: Option<GoldConfig>,
44 pub system_prompt: String,
46 pub base_system_prompt: String,
47 pub workspace_path: Option<String>,
48}
49
50pub fn resolve_default_run_config(
54 config_snapshot: &Config,
55 provider_registry: &Arc<ProviderRegistry>,
56) -> ResolvedDefaultRunConfig {
57 let mut target = default_chat_model_ref(config_snapshot);
58 let (model_provider, model_origin, model_error) = match target.as_ref() {
59 Some(requested) => match crate::model_config_helper::try_resolve_primary_model(
60 config_snapshot,
61 provider_registry,
62 requested,
63 ) {
64 Ok(selected) => {
65 target = Some(selected.target);
66 (Some(selected.provider), selected.managed_origin, None)
67 }
68 Err(error) => (None, None, Some(error.to_string())),
69 },
70 None => (None, None, None),
71 };
72 let model = target.as_ref().map(|target| target.model.clone());
73 let provider_name = Some(
74 target
75 .as_ref()
76 .map(|target| target.provider.clone())
77 .unwrap_or_else(|| config_snapshot.effective_default_provider().to_string()),
78 );
79 let provider_type = provider_name.as_deref().and_then(|name| {
80 crate::model_config_helper::resolve_provider_type(config_snapshot, name, provider_registry)
81 });
82 let capability_provider_name = provider_name
83 .as_deref()
84 .unwrap_or(config_snapshot.effective_default_provider());
85 let areas = crate::model_areas::resolve_global_area_models(
88 config_snapshot,
89 capability_provider_name,
90 provider_registry,
91 );
92 let reasoning_effort = match target.as_ref() {
93 Some(target) => target
94 .reasoning_effort
95 .or_else(|| config_snapshot.reasoning_effort_for_key(&target.provider)),
96 None => config_snapshot.get_reasoning_effort(),
97 };
98 let base_system_prompt = crate::prompt_defaults::read_global_default_system_prompt_template();
99 let workspace_path = config_snapshot
100 .get_default_work_area_path()
101 .map(|path| bamboo_config::paths::path_to_display_string(&path));
102 let system_prompt = crate::context::assemble_system_prompt(
103 &base_system_prompt,
104 None,
105 workspace_path.as_deref(),
106 );
107 let model_roster = ModelRoster::from_areas(model, provider_name, provider_type, areas);
108
109 ResolvedDefaultRunConfig {
110 model_roster,
111 model_provider,
112 model_origin,
113 model_error,
114 reasoning_effort,
115 gold_config: crate::model_config_helper::resolve_gold_config(config_snapshot, None),
116 system_prompt,
117 base_system_prompt,
118 workspace_path,
119 }
120}
121
122pub fn default_chat_model_ref(config: &Config) -> Option<ProviderModelRef> {
125 if config.features.provider_model_ref {
126 if let Some(defaults) = config.defaults.as_ref() {
127 return Some(defaults.chat.clone());
128 }
129 }
130 let provider = config.effective_default_provider();
131 let model = if let Some(instance) = config.provider_instances.get(provider) {
132 instance
134 .model
135 .clone()
136 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()))
137 } else {
138 config.get_model()
139 }?;
140 (!model.trim().is_empty()).then(|| ProviderModelRef::new(provider, model.trim()))
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 macro_rules! test_config {
148 (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
149 (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
150 (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
151 (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
152 ($($field:ident: $value:expr),* $(,)?) => {{
153 let mut config = Config::default();
154 $(test_config!(@assign config, $field, $value);)*
155 config
156 }};
157 }
158 use bamboo_agent_core::tools::ToolSchema;
159 use bamboo_agent_core::Message;
160 use bamboo_config::{DefaultsConfig, FeatureFlags, OpenAIConfig, ProviderConfigs};
161 use bamboo_domain::ProviderModelRef;
162 use bamboo_llm::{LLMError, LLMProvider, LLMStream};
163 use std::collections::HashMap;
164
165 struct NoopProvider;
166
167 #[async_trait::async_trait]
168 impl LLMProvider for NoopProvider {
169 async fn chat_stream(
170 &self,
171 _messages: &[Message],
172 _tools: &[ToolSchema],
173 _max_output_tokens: Option<u32>,
174 _model: &str,
175 ) -> Result<LLMStream, LLMError> {
176 Err(LLMError::Api("noop".to_string()))
177 }
178 }
179
180 fn test_registry() -> Arc<ProviderRegistry> {
181 let mut providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::new();
182 providers.insert("openai".to_string(), Arc::new(NoopProvider));
183 Arc::new(ProviderRegistry::new(providers, "openai".to_string()))
184 }
185
186 fn config_with_area_defaults() -> Config {
187 let defaults = DefaultsConfig {
188 chat: ProviderModelRef::new("openai", "gpt-chat"),
189 fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
190 task_summary: Some(ProviderModelRef::new("openai", "gpt-summary")),
191 vision: None,
192 memory_background: Some(ProviderModelRef::new("openai", "gpt-memory")),
193 planning: None,
194 search: None,
195 code_review: None,
196 sub_agent: None,
197 subagent_models: HashMap::new(),
198 };
199 test_config! {
200 provider: "openai".to_string(),
201 features: FeatureFlags {
202 provider_model_ref: true,
203 ..Default::default()
204 },
205 providers: ProviderConfigs {
206 openai: Some(OpenAIConfig {
207 api_key: "test-secret-key".to_string(),
208 model: Some("gpt-chat".to_string()),
209 ..Default::default()
210 }),
211 ..Default::default()
212 },
213 defaults: Some(defaults),
214 }
215 }
216
217 #[test]
220 fn resolve_default_run_config_resolves_distinct_area_models() {
221 let config = config_with_area_defaults();
222 let registry = test_registry();
223
224 let resolved = resolve_default_run_config(&config, ®istry);
225
226 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
227 assert_eq!(
228 resolved.model_roster.fast_model().as_deref(),
229 Some("gpt-fast")
230 );
231 assert_eq!(
232 resolved.model_roster.background_model().as_deref(),
233 Some("gpt-memory")
234 );
235 assert_eq!(
236 resolved.model_roster.summarization_model().as_deref(),
237 Some("gpt-summary")
238 );
239 }
240
241 #[test]
242 fn alias_routing_uses_selected_instance_reasoning_without_legacy_fallback() {
243 let mut config = config_with_area_defaults();
244 config
245 .providers_mut()
246 .openai
247 .as_mut()
248 .unwrap()
249 .reasoning_effort = Some(ReasoningEffort::High);
250 config.default_provider_instance = Some("work-openai".into());
251 config.provider_instances.insert(
252 "work-openai".into(),
253 serde_json::from_value(serde_json::json!({
254 "provider_type": "openai", "enabled": true, "model": "gpt-chat",
255 "runtime_models": ["gpt-chat"]
256 }))
257 .unwrap(),
258 );
259 let providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::from([(
260 "work-openai".into(),
261 Arc::new(NoopProvider) as Arc<dyn LLMProvider>,
262 )]);
263 let registry = Arc::new(ProviderRegistry::new(providers, "work-openai".into()));
264
265 let selected = resolve_default_run_config(&config, ®istry);
266 assert!(selected.model_error.is_none(), "{:?}", selected.model_error);
267 assert_eq!(
268 selected.model_roster.provider_name.as_deref(),
269 Some("work-openai")
270 );
271 assert_eq!(selected.reasoning_effort, None);
272
273 config
274 .provider_instances
275 .get_mut("work-openai")
276 .unwrap()
277 .reasoning_effort = Some(ReasoningEffort::Low);
278 let selected = resolve_default_run_config(&config, ®istry);
279 assert_eq!(selected.reasoning_effort, Some(ReasoningEffort::Low));
280 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Medium);
281 let selected = resolve_default_run_config(&config, ®istry);
282 assert_eq!(selected.reasoning_effort, Some(ReasoningEffort::Medium));
283 }
284
285 #[test]
286 fn missing_chat_target_retains_legacy_reasoning_default() {
287 let mut config = Config::default();
288 config.provider = "openai".into();
289 config.providers_mut().openai = Some(OpenAIConfig {
290 reasoning_effort: Some(ReasoningEffort::High),
291 ..Default::default()
292 });
293 let resolved = resolve_default_run_config(&config, &test_registry());
294 assert!(resolved.model_roster.model.is_none());
295 assert_eq!(resolved.reasoning_effort, Some(ReasoningEffort::High));
296 }
297
298 #[test]
299 fn resolve_default_run_config_never_leaks_api_keys() {
300 let config = config_with_area_defaults();
301 let registry = test_registry();
302
303 let resolved = resolve_default_run_config(&config, ®istry);
304
305 assert!(!resolved.system_prompt.contains("test-secret-key"));
306 assert!(!resolved.base_system_prompt.contains("test-secret-key"));
307 let gold_json =
308 serde_json::to_string(&resolved.gold_config).unwrap_or_else(|_| "null".to_string());
309 assert!(!gold_json.contains("test-secret-key"));
310 }
311}