1use std::sync::Arc;
22
23use bamboo_domain::reasoning::ReasoningEffort;
24use bamboo_domain::ProviderModelRef;
25use bamboo_llm::{Config, ProviderRegistry, ResolvedModel};
26
27use crate::model_config_helper::{
28 resolve_background_model, resolve_fast_model, resolve_subagent_model,
29 resolve_task_summary_model, resolve_vision_model,
30};
31
32pub struct GlobalAreaModels {
39 pub fast: Option<ResolvedModel>,
41 pub fast_ref: Option<ProviderModelRef>,
42 pub background: Option<ResolvedModel>,
44 pub background_ref: Option<ProviderModelRef>,
45 pub summarization: Option<ResolvedModel>,
47 pub summarization_ref: Option<ProviderModelRef>,
48}
49
50pub fn resolve_global_area_models(
59 config: &Config,
60 provider_name: &str,
61 provider_registry: &Arc<ProviderRegistry>,
62) -> GlobalAreaModels {
63 let refs = config
64 .defaults
65 .as_ref()
66 .filter(|_| config.features.provider_model_ref)
67 .map(|defaults| {
68 let fast = defaults.fast.as_ref().unwrap_or(&defaults.chat);
69 (
70 fast.clone(),
71 defaults.memory_background.as_ref().unwrap_or(fast).clone(),
72 defaults.task_summary.as_ref().unwrap_or(fast).clone(),
73 )
74 });
75 GlobalAreaModels {
76 fast: resolve_fast_model(config, provider_name, provider_registry),
77 fast_ref: refs.as_ref().map(|(fast, _, _)| fast.clone()),
78 background: resolve_background_model(config, provider_name, provider_registry),
79 background_ref: refs.as_ref().map(|(_, background, _)| background.clone()),
80 summarization: resolve_task_summary_model(config, provider_name, provider_registry),
81 summarization_ref: refs.map(|(_, _, summarization)| summarization),
82 }
83}
84
85pub fn resolve_global_vision_model(
89 config: &Config,
90 provider_name: &str,
91 provider_registry: &Arc<ProviderRegistry>,
92) -> Option<ResolvedModel> {
93 resolve_vision_model(config, provider_name, provider_registry)
94}
95
96pub fn resolve_global_subagent_model(
101 config: &Config,
102 provider_name: &str,
103 provider_registry: &Arc<ProviderRegistry>,
104 subagent_type: &str,
105) -> Option<ResolvedModel> {
106 resolve_subagent_model(config, provider_name, provider_registry, subagent_type)
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum ReasoningEffortSource {
113 Session,
114 Request,
115 ModelDefault,
116 ProviderDefault,
117 None,
118}
119
120impl ReasoningEffortSource {
121 pub fn as_str(self) -> &'static str {
122 match self {
123 Self::Session => "session",
124 Self::Request => "request",
125 Self::ModelDefault => "model_default",
126 Self::ProviderDefault => "provider_default",
127 Self::None => "none",
128 }
129 }
130}
131
132pub fn resolve_effective_reasoning_effort(
141 session_effort: Option<ReasoningEffort>,
142 request_effort: Option<ReasoningEffort>,
143 model_default: Option<ReasoningEffort>,
144 provider_default: Option<ReasoningEffort>,
145) -> (Option<ReasoningEffort>, ReasoningEffortSource) {
146 if let Some(effort) = session_effort {
147 (Some(effort), ReasoningEffortSource::Session)
148 } else if let Some(effort) = request_effort {
149 (Some(effort), ReasoningEffortSource::Request)
150 } else if let Some(effort) = model_default {
151 (Some(effort), ReasoningEffortSource::ModelDefault)
152 } else if let Some(effort) = provider_default {
153 (Some(effort), ReasoningEffortSource::ProviderDefault)
154 } else {
155 (None, ReasoningEffortSource::None)
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 macro_rules! test_config {
164 (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
165 (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
166 (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
167 (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
168 ($($field:ident: $value:expr),* $(,)?) => {{
169 let mut config = Config::default();
170 $(test_config!(@assign config, $field, $value);)*
171 config
172 }};
173 }
174 use bamboo_agent_core::tools::ToolSchema;
175 use bamboo_agent_core::Message;
176 use bamboo_config::{DefaultsConfig, FeatureFlags};
177 use bamboo_config::{OpenAIConfig, ProviderConfigs};
178 use bamboo_domain::{Session, DEFAULT_REASONING_EFFORT};
179 use bamboo_llm::{LLMError, LLMProvider, LLMStream};
180 use std::collections::HashMap;
181
182 struct NoopProvider;
183
184 #[async_trait::async_trait]
185 impl LLMProvider for NoopProvider {
186 async fn chat_stream(
187 &self,
188 _messages: &[Message],
189 _tools: &[ToolSchema],
190 _max_output_tokens: Option<u32>,
191 _model: &str,
192 ) -> Result<LLMStream, LLMError> {
193 Err(LLMError::Api("noop".to_string()))
194 }
195 }
196
197 fn test_registry() -> Arc<ProviderRegistry> {
198 let mut providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::new();
199 providers.insert("openai".to_string(), Arc::new(NoopProvider));
200 Arc::new(ProviderRegistry::new(providers, "openai".to_string()))
201 }
202
203 fn defaults_with_all_areas() -> DefaultsConfig {
204 DefaultsConfig {
205 chat: ProviderModelRef::new("openai", "gpt-chat"),
206 fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
207 task_summary: Some(ProviderModelRef::new("openai", "gpt-summary")),
208 vision: Some(ProviderModelRef::new("openai", "gpt-vision")),
209 memory_background: Some(ProviderModelRef::new("openai", "gpt-memory")),
210 planning: None,
211 search: None,
212 code_review: None,
213 sub_agent: Some(ProviderModelRef::new("openai", "gpt-sub")),
214 subagent_models: HashMap::new(),
215 }
216 }
217
218 fn config_with_defaults(defaults: DefaultsConfig) -> Config {
219 test_config! {
220 provider: "openai".to_string(),
221 features: FeatureFlags {
222 provider_model_ref: true,
223 ..Default::default()
224 },
225 defaults: Some(defaults),
226 }
227 }
228
229 #[test]
232 fn global_area_models_read_each_area_from_its_own_default() {
233 let config = config_with_defaults(defaults_with_all_areas());
234 let areas = resolve_global_area_models(&config, "openai", &test_registry());
235
236 assert_eq!(
237 areas.fast.as_ref().map(|m| m.model_name.as_str()),
238 Some("gpt-fast")
239 );
240 assert_eq!(
241 areas.summarization.as_ref().map(|m| m.model_name.as_str()),
242 Some("gpt-summary")
243 );
244 assert_eq!(
245 areas.background.as_ref().map(|m| m.model_name.as_str()),
246 Some("gpt-memory")
247 );
248 assert_eq!(
250 areas.fast_ref,
251 Some(ProviderModelRef::new("openai", "gpt-fast"))
252 );
253 assert_eq!(
254 areas.summarization_ref,
255 Some(ProviderModelRef::new("openai", "gpt-summary"))
256 );
257 assert_eq!(
258 areas.background_ref,
259 Some(ProviderModelRef::new("openai", "gpt-memory"))
260 );
261 }
262
263 #[test]
270 fn global_area_models_are_independent_of_any_session() {
271 let config = config_with_defaults(defaults_with_all_areas());
272 let registry = test_registry();
273
274 let before = resolve_global_area_models(&config, "openai", ®istry);
275
276 let mut session = Session::new("s1", "some-exotic-session-model");
278 session.model_ref = Some(ProviderModelRef::new("openai", "some-exotic-session-model"));
279 session.reasoning_effort = Some(ReasoningEffort::Max);
280 let _ = &session; let after = resolve_global_area_models(&config, "openai", ®istry);
283
284 assert_eq!(
285 before.fast.as_ref().map(|m| m.model_name.clone()),
286 after.fast.as_ref().map(|m| m.model_name.clone())
287 );
288 assert_eq!(
289 before.background.as_ref().map(|m| m.model_name.clone()),
290 after.background.as_ref().map(|m| m.model_name.clone())
291 );
292 assert_eq!(
293 before.summarization.as_ref().map(|m| m.model_name.clone()),
294 after.summarization.as_ref().map(|m| m.model_name.clone())
295 );
296 assert_ne!(
298 after.fast.as_ref().map(|m| m.model_name.as_str()),
299 Some("some-exotic-session-model")
300 );
301 }
302
303 #[test]
304 fn vision_model_is_global_from_defaults() {
305 let config = config_with_defaults(defaults_with_all_areas());
306 let vision = resolve_global_vision_model(&config, "openai", &test_registry());
307 assert_eq!(
308 vision.as_ref().map(|m| m.model_name.as_str()),
309 Some("gpt-vision")
310 );
311 }
312
313 #[test]
314 fn subagent_model_is_global_from_defaults() {
315 let config = config_with_defaults(defaults_with_all_areas());
316 let sub = resolve_global_subagent_model(&config, "openai", &test_registry(), "coder");
318 assert_eq!(sub.as_ref().map(|m| m.model_name.as_str()), Some("gpt-sub"));
319 }
320
321 #[test]
322 fn background_falls_back_to_fast_when_memory_background_unset() {
323 let mut defaults = defaults_with_all_areas();
324 defaults.memory_background = None;
325 let config = config_with_defaults(defaults);
326
327 let areas = resolve_global_area_models(&config, "openai", &test_registry());
328 assert_eq!(
330 areas.background.as_ref().map(|m| m.model_name.as_str()),
331 Some("gpt-fast")
332 );
333 assert_eq!(
334 areas.background_ref,
335 Some(ProviderModelRef::new("openai", "gpt-fast"))
336 );
337 }
338
339 #[test]
340 fn global_area_refs_keep_the_fallback_models_reasoning_effort() {
341 let mut defaults = defaults_with_all_areas();
342 defaults.chat = defaults.chat.with_reasoning_effort(ReasoningEffort::Max);
343 defaults.fast = None;
344 defaults.memory_background = None;
345 defaults.task_summary = None;
346 let expected = defaults.chat.clone();
347 let config = config_with_defaults(defaults);
348
349 let areas = resolve_global_area_models(&config, "openai", &test_registry());
350
351 assert_eq!(areas.fast_ref, Some(expected.clone()));
352 assert_eq!(areas.background_ref, Some(expected.clone()));
353 assert_eq!(areas.summarization_ref, Some(expected));
354 }
355
356 #[test]
357 fn legacy_mode_resolves_fast_from_provider_config() {
358 let config = test_config! {
360 provider: "openai".to_string(),
361 features: FeatureFlags {
362 provider_model_ref: false,
363 ..Default::default()
364 },
365 defaults: None,
366 providers: ProviderConfigs {
367 openai: Some(OpenAIConfig {
368 api_key: "test".to_string(),
369 api_key_from_env: false,
370 api_key_encrypted: None,
371 credential_ref: None,
372 base_url: None,
373 model: Some("gpt-4o".to_string()),
374 fast_model: Some("gpt-4o-mini".to_string()),
375 vision_model: None,
376 reasoning_effort: None,
377 responses_only_models: vec![],
378 request_overrides: None,
379 extra: Default::default(),
380 }),
381 ..ProviderConfigs::default()
382 },
383 };
384
385 let areas = resolve_global_area_models(&config, "openai", &test_registry());
386 assert_eq!(
387 areas.fast.as_ref().map(|m| m.model_name.as_str()),
388 Some("gpt-4o-mini")
389 );
390 }
391
392 #[test]
395 fn reasoning_prefers_session_then_request_then_model_then_provider() {
396 assert_eq!(
397 resolve_effective_reasoning_effort(
398 Some(ReasoningEffort::Max),
399 Some(ReasoningEffort::High),
400 Some(ReasoningEffort::Low),
401 Some(ReasoningEffort::Medium),
402 ),
403 (Some(ReasoningEffort::Max), ReasoningEffortSource::Session)
404 );
405 assert_eq!(
406 resolve_effective_reasoning_effort(
407 None,
408 Some(ReasoningEffort::High),
409 Some(ReasoningEffort::Low),
410 Some(ReasoningEffort::Medium),
411 ),
412 (Some(ReasoningEffort::High), ReasoningEffortSource::Request)
413 );
414 assert_eq!(
415 resolve_effective_reasoning_effort(
416 None,
417 None,
418 Some(ReasoningEffort::Low),
419 Some(ReasoningEffort::Medium),
420 ),
421 (
422 Some(ReasoningEffort::Low),
423 ReasoningEffortSource::ModelDefault
424 )
425 );
426 assert_eq!(
427 resolve_effective_reasoning_effort(None, None, None, Some(ReasoningEffort::Low),),
428 (
429 Some(ReasoningEffort::Low),
430 ReasoningEffortSource::ProviderDefault
431 )
432 );
433 }
434
435 #[test]
436 fn reasoning_none_when_nothing_configured() {
437 let (effort, source) = resolve_effective_reasoning_effort(None, None, None, None);
438 assert_eq!(effort, None);
439 assert_eq!(source, ReasoningEffortSource::None);
440 }
441
442 #[test]
443 fn canonical_default_is_medium_and_used_as_terminal() {
444 assert_eq!(DEFAULT_REASONING_EFFORT, ReasoningEffort::Medium);
447 let (effort, _) = resolve_effective_reasoning_effort(None, None, None, None);
448 assert_eq!(
449 effort.unwrap_or(DEFAULT_REASONING_EFFORT),
450 ReasoningEffort::Medium
451 );
452 }
453}