Skip to main content

bamboo_engine/
model_areas.rs

1//! Single, explicit boundary between **global** and **session-bound** model
2//! configuration.
3//!
4//! The product configures models for many distinct *areas* (chat, fast,
5//! task-summary, memory-background, vision, sub-agent, …). The scope rules are:
6//!
7//! - **Session-bound** — ONLY the main *chat* model + its effective reasoning
8//!   effort. A session may override these; they cascade `session → request →
9//!   selected chat role → provider default` (see `session_app::execute`).
10//! - **Global** — every *auxiliary* area (fast, task-summary, memory-background,
11//!   vision, sub-agent), including each area's optional reasoning effort. These
12//!   are read from server config (`defaults.<area>` with fast/chat fallback)
13//!   and **must never be read from a session**.
14//!
15//! This module is the one place that enforces that split. The resolver here
16//! takes no [`Session`](bamboo_domain::Session) and *cannot* — so an auxiliary
17//! area model can't accidentally start tracking a per-session override. The
18//! underlying per-area logic lives in [`crate::model_config_helper`]; this layer
19//! groups the auxiliary trio that ~8 call sites previously resolved by hand.
20
21use 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
37/// The auxiliary (non-chat) models, all resolved from **global** config for a
38/// given provider routing key. None of these are session-bound.
39///
40/// Each `*_ref` is the effective [`ProviderModelRef`] after the same fallback
41/// chain as its resolved model (or `None` without role references), kept alongside the
42/// provider so snapshot callers never lose the winning role's reasoning policy.
43pub struct GlobalAreaModels {
44    /// Fast/cheap model — title generation, lightweight tasks.
45    pub fast: Option<ResolvedModel>,
46    pub fast_ref: Option<ProviderModelRef>,
47    /// Memory/background model — reflection, background memory work.
48    pub background: Option<ResolvedModel>,
49    pub background_ref: Option<ProviderModelRef>,
50    /// Task-summary model — conversation/task summarization and compression.
51    pub summarization: Option<ResolvedModel>,
52    pub summarization_ref: Option<ProviderModelRef>,
53    /// An explicit configured route failed admission. Compression must report
54    /// this failure instead of treating it as an unset role and falling back.
55    pub summarization_error: Option<String>,
56}
57
58/// Resolve the auxiliary area models from **global** config.
59///
60/// `provider_name` is only the routing/fallback key (request's provider, or the
61/// globally active provider). It selects *which provider's* global config to
62/// fall back to when an area is unconfigured — it is never a session value, and
63/// `defaults.<area>` (when set) wins regardless of it.
64///
65/// Deliberately takes no `Session`: auxiliary models are global by design.
66pub 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/// Preserve a run's other auxiliary selections while resolving its background
105/// model and provider from the current config at every round. A busy config
106/// or a rejected route yields no background model for that round.
107#[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(&current, &provider_name, &registry))
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/// Resolve a Connect resume's summary and background from one live Config
131/// snapshot. Rejected summary roles remain errors; an absent role stays unset.
132#[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(&current, &provider_name, &registry);
156        let background = resolve_background_model(&current, &provider_name, &registry);
157        (summary, background)
158    })
159}
160
161/// Resolve all live auxiliary routes using one authoritative config read.
162/// A busy lock or missing provider key fails closed instead of reusing an older
163/// snapshot or treating unavailable routes as unset.
164#[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, &registry);
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(&current), &provider_name, &registry);
191        let background = resolve_background_model(&current, &provider_name, &registry);
192        let planning = resolve_planning_model(&current, &provider_name, &registry);
193        let search = resolve_search_model(&current, &provider_name, &registry);
194        let (summary, summarization_model_error) =
195            match try_resolve_task_summary_model(&current, &provider_name, &registry) {
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
214/// Vision model — **global**. Resolved on demand (only when a request actually
215/// carries an image), so it is not part of [`GlobalAreaModels`]'s eager trio.
216/// Still global-only: takes no session. `provider_name` is the fallback key.
217pub 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
225/// Sub-agent model for a given subagent type — **global**. Resolved on demand
226/// at spawn time. Global-only: takes no session. `provider_name` is the
227/// fallback key; `subagent_type` selects a per-type override under
228/// `defaults.subagent_models`.
229pub 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/// The source layer a resolved reasoning effort came from. Surfaced in session
239/// metadata (`reasoning_effort_source`) for observability.
240#[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
261/// The single reasoning-effort cascade:
262/// `session → request → selected model role → provider default`.
263///
264/// Returns `None` when nothing is configured (so non-reasoning models send no
265/// reasoning parameter). When a *concrete* terminal value is required (e.g. the
266/// UI display), callers should fall back to
267/// [`bamboo_domain::DEFAULT_REASONING_EFFORT`] — that is the one canonical
268/// `"medium"`, not a level hardcoded at the call site.
269pub 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    // ---- Global area models read from defaults.<area> ----
359
360    #[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        // The kept refs match the configured defaults.
378        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    /// The core invariant the user asked for: auxiliary models are GLOBAL —
393    /// they do not change with the session. We resolve once, then again after
394    /// constructing a session that picks a totally different chat model, and
395    /// assert the auxiliary models are byte-for-byte identical. (The resolver
396    /// has no `Session` parameter, so this is also enforced at compile time;
397    /// this test guards against a future signature change.)
398    #[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", &registry);
404
405        // A session whose chat model is something exotic must not influence aux.
406        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; // it is intentionally NOT passed to the resolver
410
411        let after = resolve_global_area_models(&config, "openai", &registry);
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        // And definitely not the session's chat model.
426        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        // No per-type override → falls back to defaults.sub_agent (global).
446        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        // memory_background unset → falls back to defaults.fast.
458        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        // Flag OFF: no `defaults`, fast comes from the provider's global config.
488        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    // ---- reasoning effort cascade ----
522
523    #[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        // The one place "medium" is defined; callers needing a concrete value
574        // use this rather than hardcoding a level.
575        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}