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_subagent_model,
29    resolve_task_summary_model, resolve_vision_model,
30};
31
32/// The auxiliary (non-chat) models, all resolved from **global** config for a
33/// given provider routing key. None of these are session-bound.
34///
35/// Each `*_ref` is the effective [`ProviderModelRef`] after the same fallback
36/// chain as its resolved model (or `None` in legacy mode), kept alongside the
37/// provider so snapshot callers never lose the winning role's reasoning policy.
38pub struct GlobalAreaModels {
39    /// Fast/cheap model — title generation, lightweight tasks.
40    pub fast: Option<ResolvedModel>,
41    pub fast_ref: Option<ProviderModelRef>,
42    /// Memory/background model — reflection, background memory work.
43    pub background: Option<ResolvedModel>,
44    pub background_ref: Option<ProviderModelRef>,
45    /// Task-summary model — conversation/task summarization and compression.
46    pub summarization: Option<ResolvedModel>,
47    pub summarization_ref: Option<ProviderModelRef>,
48}
49
50/// Resolve the auxiliary area models from **global** config.
51///
52/// `provider_name` is only the routing/fallback key (request's provider, or the
53/// globally active provider). It selects *which provider's* global config to
54/// fall back to when an area is unconfigured — it is never a session value, and
55/// `defaults.<area>` (when set) wins regardless of it.
56///
57/// Deliberately takes no `Session`: auxiliary models are global by design.
58pub 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
85/// Vision model — **global**. Resolved on demand (only when a request actually
86/// carries an image), so it is not part of [`GlobalAreaModels`]'s eager trio.
87/// Still global-only: takes no session. `provider_name` is the fallback key.
88pub 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
96/// Sub-agent model for a given subagent type — **global**. Resolved on demand
97/// at spawn time. Global-only: takes no session. `provider_name` is the
98/// fallback key; `subagent_type` selects a per-type override under
99/// `defaults.subagent_models`.
100pub 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/// The source layer a resolved reasoning effort came from. Surfaced in session
110/// metadata (`reasoning_effort_source`) for observability.
111#[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
132/// The single reasoning-effort cascade:
133/// `session → request → selected model role → provider default`.
134///
135/// Returns `None` when nothing is configured (so non-reasoning models send no
136/// reasoning parameter). When a *concrete* terminal value is required (e.g. the
137/// UI display), callers should fall back to
138/// [`bamboo_domain::DEFAULT_REASONING_EFFORT`] — that is the one canonical
139/// `"medium"`, not a level hardcoded at the call site.
140pub 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    // ---- Global area models read from defaults.<area> ----
230
231    #[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        // The kept refs match the configured defaults.
249        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    /// The core invariant the user asked for: auxiliary models are GLOBAL —
264    /// they do not change with the session. We resolve once, then again after
265    /// constructing a session that picks a totally different chat model, and
266    /// assert the auxiliary models are byte-for-byte identical. (The resolver
267    /// has no `Session` parameter, so this is also enforced at compile time;
268    /// this test guards against a future signature change.)
269    #[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", &registry);
275
276        // A session whose chat model is something exotic must not influence aux.
277        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; // it is intentionally NOT passed to the resolver
281
282        let after = resolve_global_area_models(&config, "openai", &registry);
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        // And definitely not the session's chat model.
297        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        // No per-type override → falls back to defaults.sub_agent (global).
317        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        // memory_background unset → falls back to defaults.fast.
329        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        // Flag OFF: no `defaults`, fast comes from the provider's global config.
359        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    // ---- reasoning effort cascade ----
393
394    #[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        // The one place "medium" is defined; callers needing a concrete value
445        // use this rather than hardcoding a level.
446        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}