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_fast_model_for_runner,
29 resolve_planning_model, resolve_search_model, resolve_subagent_model, resolve_vision_model,
30 try_resolve_task_summary_model,
31};
32
33#[cfg(test)]
34#[path = "model_areas_live_tests.rs"]
35mod live_tests;
36
37pub struct GlobalAreaModels {
44 pub fast: Option<ResolvedModel>,
46 pub fast_ref: Option<ProviderModelRef>,
47 pub background: Option<ResolvedModel>,
49 pub background_ref: Option<ProviderModelRef>,
50 pub summarization: Option<ResolvedModel>,
52 pub summarization_ref: Option<ProviderModelRef>,
53 pub summarization_error: Option<String>,
56}
57
58pub fn resolve_global_area_models(
67 config: &Config,
68 provider_name: &str,
69 provider_registry: &Arc<ProviderRegistry>,
70) -> GlobalAreaModels {
71 let refs = config.defaults.as_ref().map(|defaults| {
72 let fast = defaults.fast.as_ref().unwrap_or(&defaults.chat);
73 (
74 fast.clone(),
75 defaults.memory_background.as_ref().unwrap_or(fast).clone(),
76 )
77 });
78 let (summarization, summarization_error) =
79 match try_resolve_task_summary_model(config, provider_name, provider_registry) {
80 Ok(model) => (model, None),
81 Err(error) => {
82 tracing::warn!(%error, "Configured task-summary route is unavailable");
83 (None, Some(error.to_string()))
84 }
85 };
86 GlobalAreaModels {
87 fast: resolve_fast_model(config, provider_name, provider_registry),
88 fast_ref: refs.as_ref().map(|(fast, _)| fast.clone()),
89 background: resolve_background_model(config, provider_name, provider_registry),
90 background_ref: refs.map(|(_, background)| background),
91 summarization,
92 summarization_ref: config.defaults.as_ref().map(|defaults| {
93 defaults
94 .task_summary
95 .as_ref()
96 .or(defaults.fast.as_ref())
97 .unwrap_or(&defaults.chat)
98 .clone()
99 }),
100 summarization_error,
101 }
102}
103
104#[doc(hidden)]
108pub fn live_background_model_resolver(
109 config: Arc<tokio::sync::RwLock<Config>>,
110 registry: Arc<ProviderRegistry>,
111 provider_name: String,
112 fixed: crate::AuxiliaryModelConfig,
113) -> Arc<dyn Fn() -> crate::AuxiliaryModelConfig + Send + Sync> {
114 Arc::new(move || {
115 let background = if provider_name.is_empty() {
116 None
117 } else {
118 config
119 .try_read()
120 .ok()
121 .and_then(|current| resolve_background_model(¤t, &provider_name, ®istry))
122 };
123 let mut models = fixed.clone();
124 models.background_model_name = background.as_ref().map(|model| model.model_name.clone());
125 models.background_model_provider = background.map(|model| model.provider);
126 models
127 })
128}
129
130#[doc(hidden)]
133pub fn live_resume_auxiliary_resolver(
134 config: Arc<tokio::sync::RwLock<Config>>,
135 registry: Arc<ProviderRegistry>,
136 provider_name: String,
137) -> crate::runtime::config::ResumeAuxiliaryResolver {
138 Arc::new(move || {
139 if provider_name.is_empty() {
140 return (
141 Err(bamboo_llm::LLMError::Api(
142 "Connect resume provider is unavailable".into(),
143 )),
144 None,
145 );
146 }
147 let Ok(current) = config.try_read() else {
148 return (
149 Err(bamboo_llm::LLMError::Api(
150 "Live model configuration is unavailable for Connect resume".into(),
151 )),
152 None,
153 );
154 };
155 let summary = try_resolve_task_summary_model(¤t, &provider_name, ®istry);
156 let background = resolve_background_model(¤t, &provider_name, ®istry);
157 (summary, background)
158 })
159}
160
161#[doc(hidden)]
165pub fn live_auxiliary_model_resolver(
166 config: Arc<tokio::sync::RwLock<Config>>,
167 registry: Arc<ProviderRegistry>,
168 provider_name: String,
169) -> Arc<dyn Fn() -> crate::AuxiliaryModelConfig + Send + Sync> {
170 Arc::new(move || {
171 let current = if provider_name.trim().is_empty() {
172 None
173 } else {
174 config.try_read().ok()
175 };
176 let Some(current) = current else {
177 let (fast_model_name, fast_model_provider) =
178 resolve_fast_model_for_runner(None, &provider_name, ®istry);
179 return crate::AuxiliaryModelConfig {
180 fast_model_name,
181 fast_model_provider,
182 summarization_model_error: Some(
183 "Live model configuration or provider key is unavailable".into(),
184 ),
185 ..Default::default()
186 };
187 };
188
189 let (fast_model_name, fast_model_provider) =
190 resolve_fast_model_for_runner(Some(¤t), &provider_name, ®istry);
191 let background = resolve_background_model(¤t, &provider_name, ®istry);
192 let planning = resolve_planning_model(¤t, &provider_name, ®istry);
193 let search = resolve_search_model(¤t, &provider_name, ®istry);
194 let (summary, summarization_model_error) =
195 match try_resolve_task_summary_model(¤t, &provider_name, ®istry) {
196 Ok(summary) => (summary, None),
197 Err(error) => (None, Some(error.to_string())),
198 };
199
200 crate::AuxiliaryModelConfig {
201 fast_model_name,
202 fast_model_provider,
203 background_model_name: background.as_ref().map(|model| model.model_name.clone()),
204 background_model_provider: background.map(|model| model.provider),
205 planning_model_name: planning.map(|model| model.model_name),
206 search_model_name: search.map(|model| model.model_name),
207 summarization_model_name: summary.as_ref().map(|model| model.model_name.clone()),
208 summarization_model_provider: summary.map(|model| model.provider),
209 summarization_model_error,
210 }
211 })
212}
213
214pub fn resolve_global_vision_model(
218 config: &Config,
219 provider_name: &str,
220 provider_registry: &Arc<ProviderRegistry>,
221) -> Option<ResolvedModel> {
222 resolve_vision_model(config, provider_name, provider_registry)
223}
224
225pub fn resolve_global_subagent_model(
230 config: &Config,
231 provider_name: &str,
232 provider_registry: &Arc<ProviderRegistry>,
233 subagent_type: &str,
234) -> Option<ResolvedModel> {
235 resolve_subagent_model(config, provider_name, provider_registry, subagent_type)
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub enum ReasoningEffortSource {
242 Session,
243 Request,
244 ModelDefault,
245 ProviderDefault,
246 None,
247}
248
249impl ReasoningEffortSource {
250 pub fn as_str(self) -> &'static str {
251 match self {
252 Self::Session => "session",
253 Self::Request => "request",
254 Self::ModelDefault => "model_default",
255 Self::ProviderDefault => "provider_default",
256 Self::None => "none",
257 }
258 }
259}
260
261pub fn resolve_effective_reasoning_effort(
270 session_effort: Option<ReasoningEffort>,
271 request_effort: Option<ReasoningEffort>,
272 model_default: Option<ReasoningEffort>,
273 provider_default: Option<ReasoningEffort>,
274) -> (Option<ReasoningEffort>, ReasoningEffortSource) {
275 if let Some(effort) = session_effort {
276 (Some(effort), ReasoningEffortSource::Session)
277 } else if let Some(effort) = request_effort {
278 (Some(effort), ReasoningEffortSource::Request)
279 } else if let Some(effort) = model_default {
280 (Some(effort), ReasoningEffortSource::ModelDefault)
281 } else if let Some(effort) = provider_default {
282 (Some(effort), ReasoningEffortSource::ProviderDefault)
283 } else {
284 (None, ReasoningEffortSource::None)
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 macro_rules! test_config {
293 (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
294 (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
295 (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
296 (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
297 ($($field:ident: $value:expr),* $(,)?) => {{
298 let mut config = Config::default();
299 $(test_config!(@assign config, $field, $value);)*
300 config
301 }};
302 }
303 use bamboo_agent_core::tools::ToolSchema;
304 use bamboo_agent_core::Message;
305 use bamboo_config::{DefaultsConfig, FeatureFlags};
306 use bamboo_config::{OpenAIConfig, ProviderConfigs};
307 use bamboo_domain::{Session, DEFAULT_REASONING_EFFORT};
308 use bamboo_llm::{LLMError, LLMProvider, LLMStream};
309 use std::collections::HashMap;
310
311 struct NoopProvider;
312
313 #[async_trait::async_trait]
314 impl LLMProvider for NoopProvider {
315 async fn chat_stream(
316 &self,
317 _messages: &[Message],
318 _tools: &[ToolSchema],
319 _max_output_tokens: Option<u32>,
320 _model: &str,
321 ) -> Result<LLMStream, LLMError> {
322 Err(LLMError::Api("noop".to_string()))
323 }
324 }
325
326 fn test_registry() -> Arc<ProviderRegistry> {
327 let mut providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::new();
328 providers.insert("openai".to_string(), Arc::new(NoopProvider));
329 Arc::new(ProviderRegistry::new(providers, "openai".to_string()))
330 }
331
332 fn defaults_with_all_areas() -> DefaultsConfig {
333 DefaultsConfig {
334 chat: ProviderModelRef::new("openai", "gpt-chat"),
335 fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
336 task_summary: Some(ProviderModelRef::new("openai", "gpt-summary")),
337 vision: Some(ProviderModelRef::new("openai", "gpt-vision")),
338 memory_background: Some(ProviderModelRef::new("openai", "gpt-memory")),
339 planning: None,
340 search: None,
341 code_review: None,
342 sub_agent: Some(ProviderModelRef::new("openai", "gpt-sub")),
343 subagent_models: HashMap::new(),
344 }
345 }
346
347 fn config_with_defaults(defaults: DefaultsConfig) -> Config {
348 test_config! {
349 provider: "openai".to_string(),
350 features: FeatureFlags {
351 provider_model_ref: true,
352 ..Default::default()
353 },
354 defaults: Some(defaults),
355 }
356 }
357
358 #[test]
361 fn global_area_models_read_each_area_from_its_own_default() {
362 let config = config_with_defaults(defaults_with_all_areas());
363 let areas = resolve_global_area_models(&config, "openai", &test_registry());
364
365 assert_eq!(
366 areas.fast.as_ref().map(|m| m.model_name.as_str()),
367 Some("gpt-fast")
368 );
369 assert_eq!(
370 areas.summarization.as_ref().map(|m| m.model_name.as_str()),
371 Some("gpt-summary")
372 );
373 assert_eq!(
374 areas.background.as_ref().map(|m| m.model_name.as_str()),
375 Some("gpt-memory")
376 );
377 assert_eq!(
379 areas.fast_ref,
380 Some(ProviderModelRef::new("openai", "gpt-fast"))
381 );
382 assert_eq!(
383 areas.summarization_ref,
384 Some(ProviderModelRef::new("openai", "gpt-summary"))
385 );
386 assert_eq!(
387 areas.background_ref,
388 Some(ProviderModelRef::new("openai", "gpt-memory"))
389 );
390 }
391
392 #[test]
399 fn global_area_models_are_independent_of_any_session() {
400 let config = config_with_defaults(defaults_with_all_areas());
401 let registry = test_registry();
402
403 let before = resolve_global_area_models(&config, "openai", ®istry);
404
405 let mut session = Session::new("s1", "some-exotic-session-model");
407 session.model_ref = Some(ProviderModelRef::new("openai", "some-exotic-session-model"));
408 session.reasoning_effort = Some(ReasoningEffort::Max);
409 let _ = &session; let after = resolve_global_area_models(&config, "openai", ®istry);
412
413 assert_eq!(
414 before.fast.as_ref().map(|m| m.model_name.clone()),
415 after.fast.as_ref().map(|m| m.model_name.clone())
416 );
417 assert_eq!(
418 before.background.as_ref().map(|m| m.model_name.clone()),
419 after.background.as_ref().map(|m| m.model_name.clone())
420 );
421 assert_eq!(
422 before.summarization.as_ref().map(|m| m.model_name.clone()),
423 after.summarization.as_ref().map(|m| m.model_name.clone())
424 );
425 assert_ne!(
427 after.fast.as_ref().map(|m| m.model_name.as_str()),
428 Some("some-exotic-session-model")
429 );
430 }
431
432 #[test]
433 fn vision_model_is_global_from_defaults() {
434 let config = config_with_defaults(defaults_with_all_areas());
435 let vision = resolve_global_vision_model(&config, "openai", &test_registry());
436 assert_eq!(
437 vision.as_ref().map(|m| m.model_name.as_str()),
438 Some("gpt-vision")
439 );
440 }
441
442 #[test]
443 fn subagent_model_is_global_from_defaults() {
444 let config = config_with_defaults(defaults_with_all_areas());
445 let sub = resolve_global_subagent_model(&config, "openai", &test_registry(), "coder");
447 assert_eq!(sub.as_ref().map(|m| m.model_name.as_str()), Some("gpt-sub"));
448 }
449
450 #[test]
451 fn background_falls_back_to_fast_when_memory_background_unset() {
452 let mut defaults = defaults_with_all_areas();
453 defaults.memory_background = None;
454 let config = config_with_defaults(defaults);
455
456 let areas = resolve_global_area_models(&config, "openai", &test_registry());
457 assert_eq!(
459 areas.background.as_ref().map(|m| m.model_name.as_str()),
460 Some("gpt-fast")
461 );
462 assert_eq!(
463 areas.background_ref,
464 Some(ProviderModelRef::new("openai", "gpt-fast"))
465 );
466 }
467
468 #[test]
469 fn global_area_refs_keep_the_fallback_models_reasoning_effort() {
470 let mut defaults = defaults_with_all_areas();
471 defaults.chat = defaults.chat.with_reasoning_effort(ReasoningEffort::Max);
472 defaults.fast = None;
473 defaults.memory_background = None;
474 defaults.task_summary = None;
475 let expected = defaults.chat.clone();
476 let config = config_with_defaults(defaults);
477
478 let areas = resolve_global_area_models(&config, "openai", &test_registry());
479
480 assert_eq!(areas.fast_ref, Some(expected.clone()));
481 assert_eq!(areas.background_ref, Some(expected.clone()));
482 assert_eq!(areas.summarization_ref, Some(expected));
483 }
484
485 #[test]
486 fn legacy_mode_resolves_fast_from_provider_config() {
487 let config = test_config! {
489 provider: "openai".to_string(),
490 features: FeatureFlags {
491 provider_model_ref: false,
492 ..Default::default()
493 },
494 defaults: None,
495 providers: ProviderConfigs {
496 openai: Some(OpenAIConfig {
497 api_key: "test".to_string(),
498 api_key_from_env: false,
499 api_key_encrypted: None,
500 credential_ref: None,
501 base_url: None,
502 model: Some("gpt-4o".to_string()),
503 fast_model: Some("gpt-4o-mini".to_string()),
504 vision_model: None,
505 reasoning_effort: None,
506 responses_only_models: vec![],
507 request_overrides: None,
508 extra: Default::default(),
509 }),
510 ..ProviderConfigs::default()
511 },
512 };
513
514 let areas = resolve_global_area_models(&config, "openai", &test_registry());
515 assert_eq!(
516 areas.fast.as_ref().map(|m| m.model_name.as_str()),
517 Some("gpt-4o-mini")
518 );
519 }
520
521 #[test]
524 fn reasoning_prefers_session_then_request_then_model_then_provider() {
525 assert_eq!(
526 resolve_effective_reasoning_effort(
527 Some(ReasoningEffort::Max),
528 Some(ReasoningEffort::High),
529 Some(ReasoningEffort::Low),
530 Some(ReasoningEffort::Medium),
531 ),
532 (Some(ReasoningEffort::Max), ReasoningEffortSource::Session)
533 );
534 assert_eq!(
535 resolve_effective_reasoning_effort(
536 None,
537 Some(ReasoningEffort::High),
538 Some(ReasoningEffort::Low),
539 Some(ReasoningEffort::Medium),
540 ),
541 (Some(ReasoningEffort::High), ReasoningEffortSource::Request)
542 );
543 assert_eq!(
544 resolve_effective_reasoning_effort(
545 None,
546 None,
547 Some(ReasoningEffort::Low),
548 Some(ReasoningEffort::Medium),
549 ),
550 (
551 Some(ReasoningEffort::Low),
552 ReasoningEffortSource::ModelDefault
553 )
554 );
555 assert_eq!(
556 resolve_effective_reasoning_effort(None, None, None, Some(ReasoningEffort::Low),),
557 (
558 Some(ReasoningEffort::Low),
559 ReasoningEffortSource::ProviderDefault
560 )
561 );
562 }
563
564 #[test]
565 fn reasoning_none_when_nothing_configured() {
566 let (effort, source) = resolve_effective_reasoning_effort(None, None, None, None);
567 assert_eq!(effort, None);
568 assert_eq!(source, ReasoningEffortSource::None);
569 }
570
571 #[test]
572 fn canonical_default_is_medium_and_used_as_terminal() {
573 assert_eq!(DEFAULT_REASONING_EFFORT, ReasoningEffort::Medium);
576 let (effort, _) = resolve_effective_reasoning_effort(None, None, None, None);
577 assert_eq!(
578 effort.unwrap_or(DEFAULT_REASONING_EFFORT),
579 ReasoningEffort::Medium
580 );
581 }
582}