Skip to main content

bamboo_engine/
model_config_helper.rs

1// Helper function to extract default model from config
2// This should be used instead of hardcoding "gpt-4o-mini" or "default"
3
4use std::sync::Arc;
5
6use crate::config::GoldConfig;
7use bamboo_domain::reasoning::ReasoningEffort;
8use bamboo_llm::Config;
9use bamboo_llm::{LLMError, ProviderRegistry, ResolvedModel};
10
11pub const GOLD_CONFIG_METADATA_KEY: &str = "gold_config";
12
13/// Check the explicit image-description target without constructing a provider.
14/// Actual resolution and image dispatch remain exclusive to model-issued ViewImage.
15pub fn validate_image_reference_vision_config(
16    config: &Config,
17    active_provider: &str,
18) -> Result<(), String> {
19    crate::runtime::runner::image_fallback::validate_configured_vision_target(
20        config,
21        active_provider,
22    )
23    .map_err(|error| error.to_string())
24}
25
26/// Infer the underlying provider type from a bare model name.
27///
28/// This is the single, canonical place that maps a model-name string to a
29/// provider type. Centralising it here (TD-3) keeps the scattered
30/// `claude*`/`gpt*`/`gemini*` pattern matches from drifting apart.
31///
32/// Mapping:
33/// - `claude*` → `anthropic`
34/// - `gpt*` or an OpenAI o-series name (`o1`, `o3`, `o4-mini`, ...) → `openai`
35/// - `gemini*` → `gemini`
36/// - anything else → `None`
37///
38/// The match is case-insensitive and ignores surrounding whitespace.
39pub fn infer_provider(model_name: &str) -> Option<String> {
40    let name = model_name.trim().to_ascii_lowercase();
41    if name.is_empty() {
42        return None;
43    }
44
45    if name.starts_with("claude") {
46        return Some("anthropic".to_string());
47    }
48    if name.starts_with("gpt") || is_openai_o_series(&name) {
49        return Some("openai".to_string());
50    }
51    if name.starts_with("gemini") {
52        return Some("gemini".to_string());
53    }
54    None
55}
56
57/// Whether a (already lower-cased) model name looks like an OpenAI o-series
58/// reasoning model: an `o` immediately followed by a digit (`o1`, `o3-mini`,
59/// `o4-mini`, ...).
60fn is_openai_o_series(name: &str) -> bool {
61    let mut chars = name.chars();
62    matches!(chars.next(), Some('o')) && matches!(chars.next(), Some(c) if c.is_ascii_digit())
63}
64
65/// Resolve the underlying provider type for a provider routing key.
66///
67/// In legacy mode the routing key is already the provider type (for example
68/// `"openai"` or `"copilot"`). In multi-instance mode the routing key may be an
69/// instance id (for example `"copilot-work"`), so we first consult configured
70/// provider instances, then the live registry metadata, and finally fall back to
71/// the key itself for backward compatibility.
72pub fn resolve_provider_type(
73    config: &Config,
74    provider_name: &str,
75    provider_registry: &Arc<ProviderRegistry>,
76) -> Option<String> {
77    let trimmed = provider_name.trim();
78    if trimmed.is_empty() {
79        return None;
80    }
81
82    config
83        .provider_instances
84        .get(trimmed)
85        .map(|instance| instance.provider_type.clone())
86        .or_else(|| {
87            provider_registry
88                .get_metadata(trimmed)
89                .map(|meta| meta.provider_type)
90        })
91        .or_else(|| Some(trimmed.to_string()))
92}
93
94/// Resolve an exact runtime routing key from either an instance id or a
95/// built-in provider type used by legacy clients/sessions.
96///
97/// Exact instance ids always win, including disabled or failed instances: in
98/// those cases resolution fails closed instead of silently selecting another
99/// account. A built-in type selects the enabled default instance of that type,
100/// then the lexicographically first enabled instance, matching the model-fetch
101/// compatibility contract. The selected instance must exist in the live
102/// registry; constructor/auth failures are never replaced by the process-wide
103/// default provider.
104pub fn resolve_provider_routing_key(
105    config: &Config,
106    requested_provider: &str,
107    provider_registry: &Arc<ProviderRegistry>,
108) -> Result<String, LLMError> {
109    let requested = requested_provider.trim();
110    if requested.is_empty() {
111        return Err(LLMError::Auth("provider routing key is empty".to_string()));
112    }
113
114    let require_live = |routing_key: &str| {
115        provider_registry
116            .get(routing_key)
117            .map(|_| routing_key.to_string())
118            .ok_or_else(|| {
119                LLMError::Auth(format!(
120                    "Provider '{routing_key}' is configured but unavailable"
121                ))
122            })
123    };
124
125    if let Some(instance) = config.provider_instances.get(requested) {
126        if !instance.enabled {
127            return Err(LLMError::Auth(format!(
128                "Provider instance '{requested}' is disabled"
129            )));
130        }
131        return require_live(requested);
132    }
133
134    // Legacy and hybrid aliases are registered under their exact type key.
135    if provider_registry.get(requested).is_some() {
136        return Ok(requested.to_string());
137    }
138
139    if !bamboo_llm::AVAILABLE_PROVIDERS.contains(&requested) {
140        return Err(LLMError::Auth(format!(
141            "Unknown provider instance or type '{requested}'"
142        )));
143    }
144
145    if let Some(default_id) = config.default_provider_instance.as_deref() {
146        if let Some(instance) = config.provider_instances.get(default_id) {
147            if instance.provider_type == requested {
148                if !instance.enabled {
149                    return Err(LLMError::Auth(format!(
150                        "Default provider instance '{default_id}' is disabled"
151                    )));
152                }
153                return require_live(default_id);
154            }
155        }
156    }
157
158    let mut matching_ids = config
159        .provider_instances
160        .iter()
161        .filter(|(_, instance)| instance.enabled && instance.provider_type == requested)
162        .map(|(id, _)| id.as_str())
163        .collect::<Vec<_>>();
164    matching_ids.sort_unstable();
165    if let Some(instance_id) = matching_ids.first() {
166        return require_live(instance_id);
167    }
168
169    Err(LLMError::Auth(format!(
170        "No enabled provider instance is available for type '{requested}'"
171    )))
172}
173
174pub fn parse_session_gold_config(session_gold_config_json: Option<&str>) -> Option<GoldConfig> {
175    let raw = session_gold_config_json?.trim();
176    if raw.is_empty() {
177        return None;
178    }
179    serde_json::from_str::<GoldConfig>(raw).ok()
180}
181
182pub fn normalize_gold_config_json(value: &serde_json::Value) -> Result<String, serde_json::Error> {
183    let parsed = serde_json::from_value::<GoldConfig>(value.clone())?;
184    serde_json::to_string(&parsed)
185}
186
187pub fn resolve_global_gold_config(config: &Config) -> Option<GoldConfig> {
188    config
189        .extra
190        .get("gold")
191        .cloned()
192        .and_then(|value| serde_json::from_value::<GoldConfig>(value).ok())
193}
194
195pub fn resolve_gold_config(
196    config: &Config,
197    session_gold_config_json: Option<&str>,
198) -> Option<GoldConfig> {
199    if session_gold_config_json.is_some() {
200        return parse_session_gold_config(session_gold_config_json);
201    }
202    resolve_global_gold_config(config)
203}
204
205/// Get the default model for a specific provider from config.
206pub fn get_default_model_for_provider(
207    config: &Config,
208    provider_name: &str,
209) -> Result<String, LLMError> {
210    let provider_name = provider_name.trim();
211    if let Some(instance) = config.provider_instances.get(provider_name) {
212        if !instance.enabled {
213            return Err(LLMError::Auth(format!(
214                "Provider instance '{provider_name}' is disabled"
215            )));
216        }
217        if let Some(model) = instance
218            .model
219            .as_deref()
220            .map(str::trim)
221            .filter(|model| !model.is_empty())
222        {
223            return Ok(model.to_string());
224        }
225        if instance.provider_type == "copilot" {
226            return Ok("gpt-4o".to_string());
227        }
228        return Err(LLMError::Auth(format!(
229            "Model must be specified for provider instance '{provider_name}'"
230        )));
231    }
232
233    match provider_name {
234        "copilot" => {
235            let provider_model = config
236                .providers()
237                .copilot
238                .as_ref()
239                .and_then(|c| c.model.clone());
240
241            Ok(provider_model.unwrap_or_else(|| "gpt-4o".to_string()))
242        }
243        "openai" => {
244            let openai_config = config
245                .providers()
246                .openai
247                .as_ref()
248                .ok_or_else(|| LLMError::Auth("OpenAI configuration required".to_string()))?;
249
250            openai_config.model.clone().ok_or_else(|| {
251                LLMError::Auth("OpenAI model must be specified in config".to_string())
252            })
253        }
254        "anthropic" => {
255            let anthropic_config =
256                config.providers().anthropic.as_ref().ok_or_else(|| {
257                    LLMError::Auth("Anthropic configuration required".to_string())
258                })?;
259
260            anthropic_config.model.clone().ok_or_else(|| {
261                LLMError::Auth("Anthropic model must be specified in config".to_string())
262            })
263        }
264        "gemini" => {
265            let gemini_config = config
266                .providers()
267                .gemini
268                .as_ref()
269                .ok_or_else(|| LLMError::Auth("Gemini configuration required".to_string()))?;
270
271            gemini_config.model.clone().ok_or_else(|| {
272                LLMError::Auth("Gemini model must be specified in config".to_string())
273            })
274        }
275        other => Err(LLMError::Auth(format!("Unknown provider: {}", other))),
276    }
277}
278
279/// Get the default model for the current provider from config.
280/// Returns an error if no model is configured.
281pub fn get_default_model_from_config(config: &Config) -> Result<String, LLMError> {
282    get_default_model_for_provider(config, config.effective_default_provider())
283}
284
285/// Get the schedule auto-execute model for the current provider from config.
286///
287/// Falls back from the provider fast model to the default chat model when no
288/// dedicated fast model is configured.
289pub fn get_schedule_model_from_config(config: &Config) -> Result<String, LLMError> {
290    config
291        .get_fast_model()
292        .map(|model| model.trim().to_string())
293        .filter(|model| !model.is_empty())
294        .ok_or_else(|| {
295            LLMError::Auth(format!(
296                "No fast/default model configured for provider '{}'",
297                config.effective_default_provider()
298            ))
299        })
300}
301
302/// Get the fast/cheap model for a specific provider from config.
303pub fn get_fast_model_for_provider(config: &Config, provider_name: &str) -> Option<String> {
304    if let Some(instance) = config.provider_instances.get(provider_name.trim()) {
305        if !instance.enabled {
306            return None;
307        }
308        return instance
309            .fast_model
310            .as_deref()
311            .map(str::trim)
312            .filter(|model| !model.is_empty())
313            .map(ToString::to_string)
314            .or_else(|| get_default_model_for_provider(config, provider_name).ok());
315    }
316
317    let fast = match provider_name.trim() {
318        "openai" => config
319            .providers()
320            .openai
321            .as_ref()
322            .and_then(|c| c.fast_model.clone()),
323        "anthropic" => config
324            .providers()
325            .anthropic
326            .as_ref()
327            .and_then(|c| c.fast_model.clone()),
328        "gemini" => config
329            .providers()
330            .gemini
331            .as_ref()
332            .and_then(|c| c.fast_model.clone()),
333        "copilot" => config
334            .providers()
335            .copilot
336            .as_ref()
337            .and_then(|c| c.fast_model.clone()),
338        _ => None,
339    };
340
341    fast.or_else(|| get_default_model_for_provider(config, provider_name).ok())
342}
343
344/// Get the memory/background model for a specific provider from config.
345///
346/// This uses provider-local fast model fallback and intentionally avoids coupling
347/// to the globally active provider.
348pub fn get_memory_background_model_for_provider(
349    config: &Config,
350    provider_name: &str,
351) -> Option<String> {
352    let configured = config
353        .memory()
354        .as_ref()
355        .and_then(|memory| memory.background_model.as_ref())
356        .map(|value| value.trim())
357        .filter(|value| !value.is_empty())
358        .map(ToString::to_string);
359
360    configured.or_else(|| get_fast_model_for_provider(config, provider_name))
361}
362
363/// Get the default reasoning effort for a specific provider routing key.
364///
365/// `provider_name` is the model_ref's provider, which in multi-instance mode is
366/// an instance id (for example `"copilot-work"`). Delegates to
367/// [`Config::reasoning_effort_for_key`] so instance ids, the `bodhi` provider,
368/// and legacy provider types all resolve consistently with the session-create
369/// path.
370pub fn get_reasoning_effort_for_provider(
371    config: &Config,
372    provider_name: &str,
373) -> Option<ReasoningEffort> {
374    config.reasoning_effort_for_key(provider_name)
375}
376
377/// Get the task summarization model for the current provider from config.
378///
379/// Used for conversation/task summarization and context compression.
380pub fn get_task_summary_model_from_config(config: &Config) -> Result<String, LLMError> {
381    config.get_task_summary_model().ok_or_else(|| {
382        LLMError::Auth(format!(
383            "No task summary model configured for provider '{}'",
384            config.effective_default_provider()
385        ))
386    })
387}
388
389/// Get the memory/background summarization model for the current provider from config.
390///
391/// Used for lightweight memory tasks like session summarization and reflection.
392/// Falls back to the provider fast model when `memory.background_model` is not configured.
393pub fn get_memory_background_model_from_config(config: &Config) -> Result<String, LLMError> {
394    config.get_memory_background_model().ok_or_else(|| {
395        LLMError::Auth(format!(
396            "No background memory model configured for provider '{}'",
397            config.effective_default_provider()
398        ))
399    })
400}
401
402/// Get the vision-capable model for the current provider from config.
403///
404/// Used for image understanding tasks.
405/// Falls back to the default model when no vision_model is configured.
406pub fn get_vision_model_from_config(config: &Config) -> Result<String, LLMError> {
407    config.get_vision_model().ok_or_else(|| {
408        LLMError::Auth(format!(
409            "No model configured for provider '{}'",
410            config.effective_default_provider()
411        ))
412    })
413}
414
415fn resolve_role_ref_chain<'a>(
416    config: &Config,
417    provider_registry: &Arc<ProviderRegistry>,
418    candidates: impl IntoIterator<Item = Option<&'a bamboo_domain::ProviderModelRef>>,
419) -> Option<ResolvedModel> {
420    match try_resolve_role_ref_chain(config, provider_registry, candidates) {
421        Ok(model) => model,
422        Err(error) => {
423            tracing::warn!(%error, "Configured auxiliary model route is unavailable");
424            None
425        }
426    }
427}
428
429fn try_resolve_role_ref_chain<'a>(
430    config: &Config,
431    provider_registry: &Arc<ProviderRegistry>,
432    candidates: impl IntoIterator<Item = Option<&'a bamboo_domain::ProviderModelRef>>,
433) -> Result<Option<ResolvedModel>, LLMError> {
434    Ok(
435        try_resolve_role_ref_chain_with_origin(config, provider_registry, candidates)?
436            .map(|(model, _)| model),
437    )
438}
439
440fn try_resolve_role_ref_chain_with_origin<'a>(
441    config: &Config,
442    provider_registry: &Arc<ProviderRegistry>,
443    candidates: impl IntoIterator<Item = Option<&'a bamboo_domain::ProviderModelRef>>,
444) -> Result<Option<(ResolvedModel, bool)>, LLMError> {
445    // A present-but-invalid reference fails closed. Automatic fallback applies
446    // only when a role is unset, never when an explicit provider id is stale.
447    let Some(model_ref) = candidates.into_iter().flatten().next() else {
448        return Ok(None);
449    };
450    if model_ref.provider.trim().is_empty()
451        || model_ref.model.trim().is_empty()
452        || model_ref.provider.trim() != model_ref.provider
453        || model_ref.model.trim() != model_ref.model
454    {
455        return Err(LLMError::Api(
456            "Configured provider/model reference is invalid".into(),
457        ));
458    }
459    if let Some(instance) = config.provider_instances.get(&model_ref.provider) {
460        if !instance.enabled {
461            return Err(LLMError::Auth(format!(
462                "Provider instance '{}' is disabled",
463                model_ref.provider
464            )));
465        }
466        if bamboo_config::explicit_runtime_models(instance)
467            .map_err(LLMError::Api)?
468            .is_some_and(|models| !models.contains(&model_ref.model))
469        {
470            return Err(LLMError::Api(format!(
471                "Model '{}' is not admitted to runtime for provider '{}'",
472                model_ref.model, model_ref.provider
473            )));
474        }
475    }
476    let (provider, managed) =
477        provider_registry.provider_for_configured_model_with_origin(config, model_ref)?;
478    Ok(Some((
479        ResolvedModel::from_ref(provider, model_ref),
480        managed,
481    )))
482}
483
484pub fn try_resolve_exact_model(
485    config: &Config,
486    registry: &Arc<ProviderRegistry>,
487    target: &bamboo_domain::ProviderModelRef,
488) -> Result<ResolvedModel, LLMError> {
489    try_resolve_role_ref_chain(config, registry, [Some(target)])?
490        .ok_or_else(|| LLMError::Api("Configured model target is required".into()))
491}
492
493/// A primary route retains the canonical account, its unwrapped provider and
494/// the managed proof captured for that same provider generation.
495pub struct ResolvedPrimaryModel {
496    pub target: bamboo_domain::ProviderModelRef,
497    pub provider: Arc<dyn bamboo_llm::LLMProvider>,
498    pub managed_origin: Option<Arc<bamboo_llm::provider_registry::ManagedProviderOrigin>>,
499}
500
501pub fn try_resolve_primary_model(
502    config: &Config,
503    registry: &Arc<ProviderRegistry>,
504    requested: &bamboo_domain::ProviderModelRef,
505) -> Result<ResolvedPrimaryModel, LLMError> {
506    if requested.provider.trim().is_empty()
507        || requested.model.trim().is_empty()
508        || requested.provider.trim() != requested.provider
509        || requested.model.trim() != requested.model
510    {
511        return Err(LLMError::Api(
512            "Configured provider/model reference is invalid".into(),
513        ));
514    }
515    let mut target = requested.clone();
516    target.provider = resolve_provider_routing_key(config, &target.provider, registry)?;
517    // Primary reasoning is carried by the run request; using a role wrapper
518    // here would hide the selected registry Arc from managed Gold admission.
519    let mut raw_target = target.clone();
520    raw_target.reasoning_effort = None;
521    let (admitted, was_managed) =
522        try_resolve_role_ref_chain_with_origin(config, registry, [Some(&raw_target)])?
523            .ok_or_else(|| LLMError::Api("Configured model target is required".into()))?;
524    let (provider, managed_origin) =
525        registry.provider_for_configured_model_with_managed_origin(config, &raw_target)?;
526    if !Arc::ptr_eq(&admitted.provider, &provider) || (was_managed && managed_origin.is_none()) {
527        return Err(LLMError::Api(
528            "Provider route changed during model admission; retry".into(),
529        ));
530    }
531    Ok(ResolvedPrimaryModel {
532        target,
533        provider,
534        managed_origin,
535    })
536}
537
538fn try_resolve_legacy_role(
539    config: &Config,
540    provider_name: &str,
541    provider_registry: &Arc<ProviderRegistry>,
542    model: Option<String>,
543) -> Result<Option<ResolvedModel>, LLMError> {
544    Ok(
545        try_resolve_legacy_role_with_origin(config, provider_name, provider_registry, model)?
546            .map(|(model, _)| model),
547    )
548}
549
550fn try_resolve_legacy_role_with_origin(
551    config: &Config,
552    provider_name: &str,
553    provider_registry: &Arc<ProviderRegistry>,
554    model: Option<String>,
555) -> Result<Option<(ResolvedModel, bool)>, LLMError> {
556    if config
557        .provider_instances
558        .get(provider_name)
559        .is_some_and(|instance| !instance.enabled)
560    {
561        return Err(LLMError::Auth(format!(
562            "Provider instance '{provider_name}' is disabled"
563        )));
564    }
565    let model_ref = model.map(|model| bamboo_domain::ProviderModelRef::new(provider_name, model));
566    try_resolve_role_ref_chain_with_origin(config, provider_registry, [model_ref.as_ref()])
567}
568
569/// Resolve the task summarization model for conversation/task compression.
570///
571/// Fallback chain: `defaults.task_summary` → `defaults.fast` → `defaults.chat`.
572pub fn resolve_task_summary_model(
573    config: &Config,
574    provider_name: &str,
575    provider_registry: &Arc<ProviderRegistry>,
576) -> Option<ResolvedModel> {
577    match try_resolve_task_summary_model(config, provider_name, provider_registry) {
578        Ok(model) => model,
579        Err(error) => {
580            tracing::warn!(%error, "Configured task-summary route is unavailable");
581            None
582        }
583    }
584}
585
586/// Resolve saved global roles even when an older settings client omitted the
587/// primary-chat rollout flag. Only unset roles fall back; an explicit route
588/// failure remains an error, never a reason to switch provider accounts.
589pub fn try_resolve_task_summary_model(
590    config: &Config,
591    provider_name: &str,
592    provider_registry: &Arc<ProviderRegistry>,
593) -> Result<Option<ResolvedModel>, LLMError> {
594    if let Some(defaults) = config.defaults.as_ref() {
595        let model_ref = defaults
596            .task_summary
597            .as_ref()
598            .or(defaults.fast.as_ref())
599            .unwrap_or(&defaults.chat);
600        return try_resolve_role_ref_chain(config, provider_registry, [Some(model_ref)]);
601    }
602    try_resolve_fast_model(config, provider_name, provider_registry)
603}
604
605/// Resolve the background/fast summarization model considering both
606/// `DefaultsConfig` (ProviderModelRef) and legacy provider config paths.
607///
608/// Resolution order:
609/// 1. `defaults.memory_background` (ProviderModelRef, routed via registry)
610/// 2. `defaults.fast` (ProviderModelRef, routed via registry)
611/// 3. `defaults.chat` (ProviderModelRef, routed via registry)
612/// 4. Legacy: `memory.background_model` / provider fast/chat string + registry lookup
613pub fn resolve_background_model(
614    config: &Config,
615    provider_name: &str,
616    provider_registry: &Arc<ProviderRegistry>,
617) -> Option<ResolvedModel> {
618    match try_resolve_background_model(config, provider_name, provider_registry) {
619        Ok(model) => model,
620        Err(error) => {
621            tracing::warn!(%error, "Configured background route is unavailable");
622            None
623        }
624    }
625}
626
627pub fn try_resolve_background_model(
628    config: &Config,
629    provider_name: &str,
630    provider_registry: &Arc<ProviderRegistry>,
631) -> Result<Option<ResolvedModel>, LLMError> {
632    if let Some(defaults) = config.defaults.as_ref() {
633        return try_resolve_role_ref_chain(
634            config,
635            provider_registry,
636            [
637                defaults.memory_background.as_ref(),
638                defaults.fast.as_ref(),
639                Some(&defaults.chat),
640            ],
641        );
642    }
643    let legacy_memory = config
644        .memory()
645        .as_ref()
646        .and_then(|memory| memory.background_model.as_deref())
647        .map(str::trim)
648        .filter(|model| !model.is_empty());
649    let result = try_resolve_legacy_role(
650        config,
651        provider_name,
652        provider_registry,
653        get_memory_background_model_for_provider(config, provider_name),
654    );
655    let Err(error) = result else {
656        return result;
657    };
658    // Only an explicitly configured legacy memory override may use the
659    // managed background-only compatibility route; fast/chat stays ordinary.
660    let Some(model) = legacy_memory else {
661        return Err(error);
662    };
663    let target = bamboo_domain::ProviderModelRef::new(provider_name, model);
664    match provider_registry.try_legacy_memory_provider(config, &target)? {
665        Some(provider) => Ok(Some(ResolvedModel::from_ref(provider, &target))),
666        None => Err(error),
667    }
668}
669
670// A failed Fast selection must not look like an absent role at runner call
671// sites: the runner otherwise falls back to the main account. Keep this
672// provider local to that execution path, not the public global role resolver.
673struct RejectedFastProvider {
674    error: FastRouteError,
675}
676
677enum FastRouteError {
678    Auth(String),
679    Api(String),
680    Other(String),
681}
682
683impl FastRouteError {
684    fn from_llm_error(error: LLMError) -> Self {
685        match error {
686            LLMError::Auth(message) => Self::Auth(message),
687            LLMError::Api(message) => Self::Api(message),
688            other => Self::Other(other.to_string()),
689        }
690    }
691
692    fn to_llm_error(&self) -> LLMError {
693        match self {
694            Self::Auth(message) => LLMError::Auth(message.clone()),
695            Self::Api(message) => LLMError::Api(message.clone()),
696            Self::Other(message) => LLMError::RequestFailed(message.clone()),
697        }
698    }
699}
700
701#[async_trait::async_trait]
702impl bamboo_llm::LLMProvider for RejectedFastProvider {
703    fn known_pre_dispatch_error(&self) -> Option<LLMError> {
704        Some(self.error.to_llm_error())
705    }
706
707    async fn chat_stream(
708        &self,
709        _messages: &[bamboo_agent_core::Message],
710        _tools: &[bamboo_agent_core::ToolSchema],
711        _max_output_tokens: Option<u32>,
712        _model: &str,
713    ) -> Result<bamboo_llm::LLMStream, LLMError> {
714        Err(self.error.to_llm_error())
715    }
716
717    async fn vision_support_override(&self, _model: &str) -> Option<bool> {
718        Some(false)
719    }
720
721    async fn list_models(&self) -> Result<Vec<String>, LLMError> {
722        Err(self.error.to_llm_error())
723    }
724}
725
726/// Resolve the Fast name and provider for a runner's auxiliary evaluations.
727///
728/// `None` config represents a busy live configuration lock, not an unset
729/// role, and therefore fails closed. An absent role retains the existing
730/// `(None, None)` fallback contract. A rejected role gets a provider that
731/// returns its admission error without accessing any network or other account.
732/// Callers must pass an authoritative configuration guard, not a cached clone.
733#[doc(hidden)]
734pub fn resolve_fast_model_for_runner(
735    config: Option<&Config>,
736    provider_name: &str,
737    provider_registry: &Arc<ProviderRegistry>,
738) -> (Option<String>, Option<Arc<dyn bamboo_llm::LLMProvider>>) {
739    let Some(config) = config else {
740        let error = LLMError::Api(
741            "Live configuration is unavailable while selecting a Fast evaluation route".into(),
742        );
743        return (
744            None,
745            Some(Arc::new(RejectedFastProvider {
746                error: FastRouteError::from_llm_error(error),
747            })),
748        );
749    };
750
751    match try_resolve_fast_model_with_origin(config, provider_name, provider_registry) {
752        Ok(Some((model, managed))) => {
753            let provider = if managed {
754                bamboo_llm::provider::mark_host_queued_route(model.provider)
755            } else {
756                model.provider
757            };
758            (Some(model.model_name), Some(provider))
759        }
760        Ok(None) => (None, None),
761        Err(error) => {
762            let model_name = config
763                .defaults
764                .as_ref()
765                .map(|defaults| {
766                    defaults
767                        .fast
768                        .as_ref()
769                        .unwrap_or(&defaults.chat)
770                        .model
771                        .clone()
772                })
773                .or_else(|| get_fast_model_for_provider(config, provider_name));
774            (
775                model_name,
776                Some(Arc::new(RejectedFastProvider {
777                    error: FastRouteError::from_llm_error(error),
778                })),
779            )
780        }
781    }
782}
783
784/// Resolve the fast model for lightweight tasks like title generation.
785pub fn resolve_fast_model(
786    config: &Config,
787    provider_name: &str,
788    provider_registry: &Arc<ProviderRegistry>,
789) -> Option<ResolvedModel> {
790    match try_resolve_fast_model(config, provider_name, provider_registry) {
791        Ok(model) => model,
792        Err(error) => {
793            tracing::warn!(%error, "Configured fast route is unavailable");
794            None
795        }
796    }
797}
798
799pub fn try_resolve_fast_model(
800    config: &Config,
801    provider_name: &str,
802    provider_registry: &Arc<ProviderRegistry>,
803) -> Result<Option<ResolvedModel>, LLMError> {
804    Ok(
805        try_resolve_fast_model_with_origin(config, provider_name, provider_registry)?
806            .map(|(model, _)| model),
807    )
808}
809
810fn try_resolve_fast_model_with_origin(
811    config: &Config,
812    provider_name: &str,
813    provider_registry: &Arc<ProviderRegistry>,
814) -> Result<Option<(ResolvedModel, bool)>, LLMError> {
815    if let Some(defaults) = config.defaults.as_ref() {
816        return try_resolve_role_ref_chain_with_origin(
817            config,
818            provider_registry,
819            [defaults.fast.as_ref(), Some(&defaults.chat)],
820        );
821    }
822    try_resolve_legacy_role_with_origin(
823        config,
824        provider_name,
825        provider_registry,
826        get_fast_model_for_provider(config, provider_name),
827    )
828}
829
830/// Resolve the vision-capable model for image understanding.
831pub fn resolve_vision_model(
832    config: &Config,
833    provider_name: &str,
834    provider_registry: &Arc<ProviderRegistry>,
835) -> Option<ResolvedModel> {
836    if let Some(defaults) = config.defaults.as_ref() {
837        return resolve_role_ref_chain(
838            config,
839            provider_registry,
840            [
841                defaults.vision.as_ref(),
842                defaults.fast.as_ref(),
843                Some(&defaults.chat),
844            ],
845        );
846    }
847    let model_name = if let Some(instance) = config.provider_instances.get(provider_name) {
848        if !instance.enabled {
849            return None;
850        }
851        instance
852            .vision_model
853            .as_deref()
854            .map(str::trim)
855            .filter(|model| !model.is_empty())
856            .map(ToString::to_string)
857            .or_else(|| get_fast_model_for_provider(config, provider_name))?
858    } else {
859        config.get_vision_model()?
860    };
861    let provider = provider_registry.get(provider_name)?;
862    Some(ResolvedModel::new(provider, model_name))
863}
864
865/// Resolve the planning/coordination model for architecture and task decomposition.
866///
867/// Fallback chain: `defaults.planning` → `defaults.fast` → `defaults.chat`.
868pub fn resolve_planning_model(
869    config: &Config,
870    provider_name: &str,
871    provider_registry: &Arc<ProviderRegistry>,
872) -> Option<ResolvedModel> {
873    if let Some(defaults) = config.defaults.as_ref() {
874        return resolve_role_ref_chain(
875            config,
876            provider_registry,
877            [
878                defaults.planning.as_ref(),
879                defaults.fast.as_ref(),
880                Some(&defaults.chat),
881            ],
882        );
883    }
884    resolve_fast_model(config, provider_name, provider_registry)
885}
886
887/// Resolve the search/navigation model for grep, file listing, and symbol resolution.
888///
889/// Fallback chain: `defaults.search` → `defaults.fast` → legacy fast model → default chat model.
890pub fn resolve_search_model(
891    config: &Config,
892    provider_name: &str,
893    provider_registry: &Arc<ProviderRegistry>,
894) -> Option<ResolvedModel> {
895    if let Some(defaults) = config.defaults.as_ref() {
896        return resolve_role_ref_chain(
897            config,
898            provider_registry,
899            [
900                defaults.search.as_ref(),
901                defaults.fast.as_ref(),
902                Some(&defaults.chat),
903            ],
904        );
905    }
906    resolve_fast_model(config, provider_name, provider_registry)
907}
908
909/// Resolve the code review model for PR and code analysis tasks.
910///
911/// Fallback chain: `defaults.code_review` → `defaults.fast` → `defaults.chat`.
912pub fn resolve_code_review_model(
913    config: &Config,
914    provider_name: &str,
915    provider_registry: &Arc<ProviderRegistry>,
916) -> Option<ResolvedModel> {
917    if let Some(defaults) = config.defaults.as_ref() {
918        return resolve_role_ref_chain(
919            config,
920            provider_registry,
921            [
922                defaults.code_review.as_ref(),
923                defaults.fast.as_ref(),
924                Some(&defaults.chat),
925            ],
926        );
927    }
928    resolve_fast_model(config, provider_name, provider_registry)
929}
930
931/// Resolve the provider+model reference for a specific subagent type.
932///
933/// Fallback chain: `defaults.subagent_models[type]` → `defaults.sub_agent` →
934/// `defaults.fast`/legacy fast → `defaults.chat`/legacy default.
935pub fn resolve_subagent_model_ref(
936    config: &Config,
937    provider_name: &str,
938    _provider_registry: &Arc<ProviderRegistry>,
939    subagent_type: &str,
940) -> Option<bamboo_domain::ProviderModelRef> {
941    if let Some(defaults) = config.defaults.as_ref() {
942        let candidate_refs = [
943            defaults.subagent_models.get(subagent_type),
944            defaults.sub_agent.as_ref(),
945            defaults.fast.as_ref(),
946            Some(&defaults.chat),
947        ];
948
949        let model_ref = candidate_refs.into_iter().flatten().next()?;
950        // This is selection, not admission. Preserve an explicit invalid ref
951        // so the real child admission rejects it rather than inheriting the
952        // parent's unrelated model after `None`.
953        return Some(model_ref.clone());
954    }
955
956    let legacy_model = if let Some(instance) = config.provider_instances.get(provider_name) {
957        // Retain a disabled instance's configured intent for final admission.
958        instance
959            .fast_model
960            .as_deref()
961            .map(str::trim)
962            .filter(|model| !model.is_empty())
963            .or_else(|| {
964                instance
965                    .model
966                    .as_deref()
967                    .map(str::trim)
968                    .filter(|model| !model.is_empty())
969            })
970            .map(ToString::to_string)
971            .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()))
972    } else {
973        // Legacy provider entries historically treated a blank fast selection
974        // as unset and inherited the parent rather than selecting an empty ID.
975        get_fast_model_for_provider(config, provider_name).filter(|model| !model.trim().is_empty())
976    };
977    legacy_model.map(|model| bamboo_domain::ProviderModelRef::new(provider_name, model))
978}
979
980/// Resolve the model for a specific subagent type.
981///
982/// Fallback chain: `defaults.subagent_models[type]` → `defaults.sub_agent` →
983/// `defaults.fast`/legacy fast → `defaults.chat`/legacy default.
984pub fn resolve_subagent_model(
985    config: &Config,
986    provider_name: &str,
987    provider_registry: &Arc<ProviderRegistry>,
988    subagent_type: &str,
989) -> Option<ResolvedModel> {
990    let model_ref =
991        resolve_subagent_model_ref(config, provider_name, provider_registry, subagent_type)?;
992    try_resolve_exact_model(config, provider_registry, &model_ref).ok()
993}
994
995/// Resolve the image-fallback configuration from a config snapshot.
996///
997/// Returns `Ok(None)` when image fallback is disabled, `Ok(Some(..))` with the
998/// resolved mode (and vision model, for `Vision` mode), or `Err` on an invalid
999/// `hooks.image_fallback.mode` value.
1000pub fn resolve_image_fallback(
1001    config_snapshot: &Config,
1002) -> Result<Option<crate::ImageFallbackConfig>, String> {
1003    use crate::ImageFallbackMode;
1004
1005    if !config_snapshot.hooks.image_fallback.enabled {
1006        return Ok(None);
1007    }
1008
1009    let mode_str = config_snapshot
1010        .hooks
1011        .image_fallback
1012        .mode
1013        .trim()
1014        .to_ascii_lowercase();
1015
1016    let mode = match mode_str.as_str() {
1017        "placeholder" => ImageFallbackMode::Placeholder,
1018        "error" => ImageFallbackMode::Error,
1019        "ocr" => ImageFallbackMode::Ocr,
1020        "vision" => ImageFallbackMode::Vision,
1021        other => {
1022            return Err(format!(
1023                "Invalid config: hooks.image_fallback.mode must be 'placeholder', 'error', 'ocr', or 'vision' (got '{other}')"
1024            ));
1025        }
1026    };
1027
1028    let vision_model = if mode == ImageFallbackMode::Vision {
1029        config_snapshot
1030            .defaults
1031            .as_ref()
1032            .map(|defaults| {
1033                defaults
1034                    .vision
1035                    .as_ref()
1036                    .or(defaults.fast.as_ref())
1037                    .unwrap_or(&defaults.chat)
1038                    .model
1039                    .clone()
1040            })
1041            .or_else(|| config_snapshot.get_vision_model())
1042    } else {
1043        None
1044    };
1045
1046    Ok(Some(crate::ImageFallbackConfig { mode, vision_model }))
1047}
1048
1049/// Mark a Host-derived fallback for selection from the execution-time config.
1050/// The public low-level resolver retains its snapshot-model behavior; only a
1051/// caller that knows this value came from config should use this conversion.
1052#[doc(hidden)]
1053pub fn live_config_image_fallback(
1054    mut fallback: Option<crate::ImageFallbackConfig>,
1055) -> Option<crate::ImageFallbackConfig> {
1056    if let Some(fallback) = fallback.as_mut() {
1057        if fallback.mode == crate::ImageFallbackMode::Vision {
1058            fallback.vision_model = None;
1059        }
1060    }
1061    fallback
1062}
1063
1064/// Resolve a Host-derived mode, leaving the Vision pair to the runtime's one
1065/// execution-time config snapshot rather than retaining a model-only value.
1066#[doc(hidden)]
1067pub fn resolve_live_image_fallback(
1068    config: &Config,
1069) -> Result<Option<crate::ImageFallbackConfig>, String> {
1070    resolve_image_fallback(config).map(live_config_image_fallback)
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076    use bamboo_agent_core::tools::ToolSchema;
1077    use bamboo_agent_core::Message;
1078    use bamboo_config::CopilotConfig;
1079    use bamboo_config::DefaultsConfig;
1080    use bamboo_config::{OpenAIConfig, ProviderConfigs};
1081    use bamboo_domain::ProviderModelRef;
1082    use bamboo_llm::{LLMProvider, LLMStream};
1083    use std::collections::HashMap;
1084
1085    macro_rules! test_config {
1086        (@assign $config:ident, providers, $value:expr) => {
1087            *$config.providers_mut() = $value;
1088        };
1089        (@assign $config:ident, memory, $value:expr) => {
1090            *$config.memory_mut() = $value;
1091        };
1092        (@assign $config:ident, subagents, $value:expr) => {
1093            *$config.subagents_mut() = $value;
1094        };
1095        (@assign $config:ident, $field:ident, $value:expr) => {
1096            $config.$field = $value;
1097        };
1098        ($($field:ident: $value:expr),* $(,)?) => {{
1099            let mut config = Config::default();
1100            $(test_config!(@assign config, $field, $value);)*
1101            config
1102        }};
1103    }
1104
1105    struct NoopProvider;
1106
1107    #[async_trait::async_trait]
1108    impl LLMProvider for NoopProvider {
1109        async fn chat_stream(
1110            &self,
1111            _messages: &[Message],
1112            _tools: &[ToolSchema],
1113            _max_output_tokens: Option<u32>,
1114            _model: &str,
1115        ) -> Result<LLMStream, LLMError> {
1116            Err(LLMError::Api("noop".to_string()))
1117        }
1118    }
1119
1120    fn test_registry() -> Arc<ProviderRegistry> {
1121        let mut providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::new();
1122        providers.insert("openai".to_string(), Arc::new(NoopProvider));
1123        Arc::new(ProviderRegistry::new(providers, "openai".to_string()))
1124    }
1125
1126    fn registry_with_provider_ids(ids: &[&str], default: &str) -> Arc<ProviderRegistry> {
1127        let providers = ids
1128            .iter()
1129            .map(|id| {
1130                (
1131                    (*id).to_string(),
1132                    Arc::new(NoopProvider) as Arc<dyn LLMProvider>,
1133                )
1134            })
1135            .collect();
1136        Arc::new(ProviderRegistry::new(providers, default.to_string()))
1137    }
1138
1139    #[tokio::test]
1140    async fn legacy_background_resolution_keeps_session_provider_and_fast_fallback() {
1141        let mut config = Config::default();
1142        config.default_provider_instance = Some("account-a".into());
1143        config.defaults = None;
1144        for (id, model, key) in [
1145            ("account-a", "chat-a", "synthetic-key-a"),
1146            ("account-b", "chat-b", "synthetic-key-b"),
1147        ] {
1148            config.provider_instances.insert(
1149                id.into(),
1150                serde_json::from_value(serde_json::json!({
1151                    "provider_type":"openai", "enabled":true, "api_key":key,
1152                    "base_url":"http://127.0.0.1:1", "model":model
1153                }))
1154                .unwrap(),
1155            );
1156        }
1157        config
1158            .memory_mut()
1159            .get_or_insert_with(Default::default)
1160            .background_model = Some("memory-v1".into());
1161        let dir = tempfile::tempdir().unwrap();
1162        let registry = Arc::new(
1163            ProviderRegistry::from_config(&config, dir.path().into())
1164                .await
1165                .unwrap(),
1166        );
1167        let b = registry.get("account-b").unwrap();
1168        for flag in [false, true] {
1169            config.features.provider_model_ref = flag;
1170            let resolved = try_resolve_background_model(&config, "account-b", &registry)
1171                .unwrap()
1172                .unwrap();
1173            assert_eq!(resolved.model_name, "memory-v1");
1174            assert!(Arc::ptr_eq(&resolved.provider, &b));
1175        }
1176        config.memory_mut().as_mut().unwrap().background_model = Some("memory-v2".into());
1177        let live = try_resolve_background_model(&config, "account-b", &registry)
1178            .unwrap()
1179            .unwrap();
1180        assert_eq!(live.model_name, "memory-v2");
1181        assert!(Arc::ptr_eq(&live.provider, &b));
1182        config
1183            .provider_instances
1184            .get_mut("account-b")
1185            .unwrap()
1186            .extra
1187            .insert("runtime_models".into(), serde_json::json!(["chat-b"]));
1188        assert!(try_resolve_background_model(&config, "account-b", &registry).is_err());
1189        config
1190            .provider_instances
1191            .get_mut("account-b")
1192            .unwrap()
1193            .extra
1194            .remove("runtime_models");
1195        config.memory_mut().as_mut().unwrap().background_model = None;
1196        let fast = try_resolve_background_model(&config, "account-b", &registry)
1197            .unwrap()
1198            .unwrap();
1199        assert_eq!(fast.model_name, "chat-b");
1200        assert!(Arc::ptr_eq(&fast.provider, &b));
1201    }
1202
1203    #[test]
1204    fn routing_contract_saved_auxiliary_roles_keep_complete_targets_for_every_chat_flag() {
1205        use crate::app_context::model_routing_test_support::RoutingContext;
1206        for flag in [None, Some(false), Some(true)] {
1207            let state = RoutingContext::new(flag);
1208            let mut config = state.config.try_read().unwrap().clone();
1209            let defaults = config.defaults.as_mut().unwrap();
1210            let memory = ProviderModelRef::new("memory-c", "background-c");
1211            let fast = ProviderModelRef::new("fast-b", "cheap-b");
1212            defaults.task_summary = Some(memory.clone());
1213            defaults.vision = Some(memory.clone());
1214            defaults.planning = Some(memory.clone());
1215            defaults.search = Some(fast.clone());
1216            defaults.code_review = Some(memory.clone());
1217            defaults.sub_agent = Some(fast.clone());
1218            defaults.subagent_models.insert("planner".into(), memory);
1219            // Reserved planning/search/code-review selectors are tested as
1220            // selectors only: they do not constitute a wired model consumer.
1221            for (resolved, provider, model) in [
1222                (
1223                    resolve_fast_model(&config, "chat-a", &state.registry),
1224                    "fast-b",
1225                    "cheap-b",
1226                ),
1227                (
1228                    resolve_task_summary_model(&config, "chat-a", &state.registry),
1229                    "memory-c",
1230                    "background-c",
1231                ),
1232                (
1233                    resolve_background_model(&config, "chat-a", &state.registry),
1234                    "memory-c",
1235                    "background-c",
1236                ),
1237                (
1238                    resolve_vision_model(&config, "chat-a", &state.registry),
1239                    "memory-c",
1240                    "background-c",
1241                ),
1242                (
1243                    resolve_planning_model(&config, "chat-a", &state.registry),
1244                    "memory-c",
1245                    "background-c",
1246                ),
1247                (
1248                    resolve_search_model(&config, "chat-a", &state.registry),
1249                    "fast-b",
1250                    "cheap-b",
1251                ),
1252                (
1253                    resolve_code_review_model(&config, "chat-a", &state.registry),
1254                    "memory-c",
1255                    "background-c",
1256                ),
1257                (
1258                    resolve_subagent_model(&config, "chat-a", &state.registry, "coder"),
1259                    "fast-b",
1260                    "cheap-b",
1261                ),
1262                (
1263                    resolve_subagent_model(&config, "chat-a", &state.registry, "planner"),
1264                    "memory-c",
1265                    "background-c",
1266                ),
1267            ] {
1268                let resolved = resolved.unwrap();
1269                assert_eq!(resolved.model_name, model, "flag {flag:?}");
1270                assert!(Arc::ptr_eq(
1271                    &resolved.provider,
1272                    &state.registry.get(provider).unwrap()
1273                ));
1274            }
1275            state.assert_no_requests();
1276        }
1277    }
1278
1279    #[test]
1280    fn primary_route_canonicalizes_alias_and_keeps_raw_provider_and_effort() {
1281        let mut config = Config::default();
1282        config.provider_instances.insert(
1283            "work-openai".into(),
1284            serde_json::from_value(serde_json::json!({
1285                "provider_type":"openai", "enabled":true, "model":"chat-model",
1286                "runtime_models":["chat-model"]
1287            }))
1288            .unwrap(),
1289        );
1290        config.default_provider_instance = Some("work-openai".into());
1291        let registry = registry_with_provider_ids(&["work-openai"], "work-openai");
1292        let target = bamboo_domain::ProviderModelRef::new("openai", "chat-model")
1293            .with_reasoning_effort(ReasoningEffort::High);
1294        let selected = try_resolve_primary_model(&config, &registry, &target).unwrap();
1295        assert_eq!(selected.target.provider, "work-openai");
1296        assert_eq!(
1297            selected.target.reasoning_effort,
1298            Some(ReasoningEffort::High)
1299        );
1300        assert!(Arc::ptr_eq(
1301            &selected.provider,
1302            &registry.get("work-openai").unwrap()
1303        ));
1304        assert!(selected.managed_origin.is_none());
1305
1306        config.provider_instances.insert(
1307            "openai".into(),
1308            serde_json::from_value(serde_json::json!({
1309                "provider_type":"openai", "enabled":false, "model":"chat-model"
1310            }))
1311            .unwrap(),
1312        );
1313        let error = try_resolve_primary_model(&config, &registry, &target)
1314            .err()
1315            .expect("a disabled exact ID must not route to an alias");
1316        assert!(error.to_string().contains("disabled"));
1317    }
1318
1319    #[test]
1320    fn subagent_resolved_model_requires_exact_current_admission() {
1321        use crate::app_context::model_routing_test_support::RoutingContext;
1322
1323        let state = RoutingContext::new(None);
1324        let mut config = state.config.try_read().unwrap().clone();
1325        config.defaults.as_mut().unwrap().sub_agent =
1326            Some(ProviderModelRef::new("fast-b", "cheap-b"));
1327        let resolved = resolve_subagent_model(&config, "chat-a", &state.registry, "coder")
1328            .expect("the selected admitted role must resolve");
1329        assert_eq!(resolved.model_name, "cheap-b");
1330        assert!(Arc::ptr_eq(
1331            &resolved.provider,
1332            &state.registry.get("fast-b").unwrap()
1333        ));
1334
1335        let instance = config.provider_instances.get_mut("fast-b").unwrap();
1336        instance.enabled = false;
1337        assert!(resolve_subagent_model(&config, "chat-a", &state.registry, "coder").is_none());
1338
1339        let instance = config.provider_instances.get_mut("fast-b").unwrap();
1340        instance.enabled = true;
1341        instance
1342            .extra
1343            .insert("runtime_models".into(), serde_json::json!([]));
1344        assert!(resolve_subagent_model(&config, "chat-a", &state.registry, "coder").is_none());
1345        state.assert_no_requests();
1346    }
1347
1348    #[test]
1349    fn provider_type_alias_resolves_to_matching_default_instance() {
1350        let mut config = Config::default();
1351        config.provider_instances.insert(
1352            "work-openai".to_string(),
1353            serde_json::from_value(serde_json::json!({
1354                "provider_type": "openai",
1355                "enabled": true
1356            }))
1357            .unwrap(),
1358        );
1359        config.provider_instances.insert(
1360            "main-anthropic".to_string(),
1361            serde_json::from_value(serde_json::json!({
1362                "provider_type": "anthropic",
1363                "enabled": true
1364            }))
1365            .unwrap(),
1366        );
1367        config.default_provider_instance = Some("main-anthropic".to_string());
1368        let registry =
1369            registry_with_provider_ids(&["main-anthropic", "work-openai"], "main-anthropic");
1370
1371        assert_eq!(
1372            resolve_provider_routing_key(&config, "openai", &registry).unwrap(),
1373            "work-openai"
1374        );
1375        assert_eq!(
1376            resolve_provider_routing_key(&config, "anthropic", &registry).unwrap(),
1377            "main-anthropic"
1378        );
1379    }
1380
1381    #[test]
1382    fn provider_type_alias_uses_lexical_instance_but_exact_unavailable_id_fails_closed() {
1383        let mut config = Config::default();
1384        for id in ["z-openai", "a-openai"] {
1385            config.provider_instances.insert(
1386                id.to_string(),
1387                serde_json::from_value(serde_json::json!({
1388                    "provider_type": "openai",
1389                    "enabled": true
1390                }))
1391                .unwrap(),
1392            );
1393        }
1394        config.provider_instances.insert(
1395            "openai".to_string(),
1396            serde_json::from_value(serde_json::json!({
1397                "provider_type": "openai",
1398                "enabled": false
1399            }))
1400            .unwrap(),
1401        );
1402        let registry = registry_with_provider_ids(&["a-openai", "z-openai"], "a-openai");
1403
1404        let error = resolve_provider_routing_key(&config, "openai", &registry)
1405            .unwrap_err()
1406            .to_string();
1407        assert!(error.contains("disabled"));
1408
1409        config.provider_instances.remove("openai");
1410        assert_eq!(
1411            resolve_provider_routing_key(&config, "openai", &registry).unwrap(),
1412            "a-openai"
1413        );
1414        assert!(resolve_provider_routing_key(&config, "typo", &registry).is_err());
1415    }
1416
1417    #[test]
1418    fn test_get_model_from_openai_config() {
1419        let config = test_config! {
1420            provider: "openai".to_string(),
1421            providers: ProviderConfigs {
1422                openai: Some(OpenAIConfig {
1423                    api_key: "test".to_string(),
1424                    api_key_from_env: false,
1425                    api_key_encrypted: None,
1426                    credential_ref: None,
1427                    base_url: None,
1428                    model: Some("gpt-4o".to_string()),
1429                    fast_model: None,
1430                    vision_model: None,
1431                    reasoning_effort: None,
1432                    responses_only_models: vec![],
1433                    request_overrides: None,
1434                    extra: Default::default(),
1435                }),
1436                ..ProviderConfigs::default()
1437            },
1438        };
1439
1440        let result = get_default_model_from_config(&config);
1441        assert!(result.is_ok());
1442        assert_eq!(result.unwrap(), "gpt-4o");
1443    }
1444
1445    #[test]
1446    fn instance_models_are_authoritative_over_stale_legacy_slot() {
1447        let mut config = test_config! {
1448            provider: "openai".to_string(),
1449            providers: ProviderConfigs {
1450                openai: Some(OpenAIConfig {
1451                    api_key: "legacy".to_string(),
1452                    model: Some("gpt-stale".to_string()),
1453                    fast_model: Some("gpt-stale-fast".to_string()),
1454                    vision_model: Some("gpt-stale-vision".to_string()),
1455                    ..OpenAIConfig::default()
1456                }),
1457                ..ProviderConfigs::default()
1458            },
1459        };
1460        config.provider_instances.insert(
1461            "work".to_string(),
1462            serde_json::from_value(serde_json::json!({
1463                "provider_type": "openai",
1464                "model": "gpt-instance",
1465                "fast_model": "gpt-instance-fast",
1466                "vision_model": "gpt-instance-vision",
1467                "enabled": true
1468            }))
1469            .unwrap(),
1470        );
1471        config.default_provider_instance = Some("work".to_string());
1472
1473        assert_eq!(
1474            get_default_model_from_config(&config).unwrap(),
1475            "gpt-instance"
1476        );
1477        assert_eq!(
1478            get_fast_model_for_provider(&config, "work").as_deref(),
1479            Some("gpt-instance-fast")
1480        );
1481        assert_eq!(
1482            get_schedule_model_from_config(&config).unwrap(),
1483            "gpt-instance-fast"
1484        );
1485        assert_eq!(
1486            get_vision_model_from_config(&config).unwrap(),
1487            "gpt-instance-vision"
1488        );
1489    }
1490
1491    #[test]
1492    fn disabled_instance_model_does_not_fall_back_to_legacy_provider() {
1493        let mut config = test_config! {
1494            provider: "openai".to_string(),
1495            providers: ProviderConfigs {
1496                openai: Some(OpenAIConfig {
1497                    model: Some("gpt-stale".to_string()),
1498                    ..OpenAIConfig::default()
1499                }),
1500                ..ProviderConfigs::default()
1501            },
1502        };
1503        config.provider_instances.insert(
1504            "work".to_string(),
1505            serde_json::from_value(serde_json::json!({
1506                "provider_type": "openai",
1507                "model": "gpt-instance",
1508                "enabled": false
1509            }))
1510            .unwrap(),
1511        );
1512        config.default_provider_instance = Some("work".to_string());
1513
1514        let error = get_default_model_from_config(&config)
1515            .unwrap_err()
1516            .to_string();
1517        assert!(error.contains("disabled"));
1518        assert!(get_fast_model_for_provider(&config, "work").is_none());
1519    }
1520
1521    #[test]
1522    fn test_error_when_model_not_configured() {
1523        let config = test_config! {
1524            provider: "openai".to_string(),
1525            providers: ProviderConfigs {
1526                openai: Some(OpenAIConfig {
1527                    api_key: "test".to_string(),
1528                    api_key_from_env: false,
1529                    api_key_encrypted: None,
1530                    credential_ref: None,
1531                    base_url: None,
1532                    model: None, // No model configured
1533                    fast_model: None,
1534                    vision_model: None,
1535                    reasoning_effort: None,
1536                    responses_only_models: vec![],
1537                    request_overrides: None,
1538                    extra: Default::default(),
1539                }),
1540                ..ProviderConfigs::default()
1541            },
1542        };
1543
1544        let result = get_default_model_from_config(&config);
1545        assert!(result.is_err());
1546        assert!(result
1547            .unwrap_err()
1548            .to_string()
1549            .contains("model must be specified"));
1550    }
1551
1552    #[test]
1553    fn test_get_model_from_copilot_provider_config() {
1554        let config = test_config! {
1555            provider: "copilot".to_string(),
1556            providers: ProviderConfigs {
1557                copilot: Some(CopilotConfig {
1558                    enabled: true,
1559                    headless_auth: false,
1560                    model: Some("gpt-4o-mini".to_string()),
1561                    fast_model: None,
1562                    vision_model: None,
1563                    reasoning_effort: None,
1564                    responses_only_models: vec![],
1565                    request_overrides: None,
1566                    extra: Default::default(),
1567                }),
1568                ..ProviderConfigs::default()
1569            },
1570        };
1571
1572        let result = get_default_model_from_config(&config);
1573        assert!(result.is_ok());
1574        assert_eq!(result.unwrap(), "gpt-4o-mini");
1575    }
1576
1577    #[test]
1578    fn test_get_model_from_copilot_default_fallback() {
1579        let config = test_config! {
1580            provider: "copilot".to_string(),
1581            providers: ProviderConfigs::default(),
1582        };
1583
1584        let result = get_default_model_from_config(&config);
1585        assert!(result.is_ok());
1586        assert_eq!(result.unwrap(), "gpt-4o");
1587    }
1588
1589    #[test]
1590    fn test_get_default_model_for_specific_provider() {
1591        let config = test_config! {
1592            provider: "anthropic".to_string(),
1593            providers: ProviderConfigs {
1594                openai: Some(OpenAIConfig {
1595                    api_key: "test".to_string(),
1596                    api_key_from_env: false,
1597                    api_key_encrypted: None,
1598                    credential_ref: None,
1599                    base_url: None,
1600                    model: Some("gpt-4o".to_string()),
1601                    fast_model: Some("gpt-4o-mini".to_string()),
1602                    vision_model: None,
1603                    reasoning_effort: Some(ReasoningEffort::Medium),
1604                    responses_only_models: vec![],
1605                    request_overrides: None,
1606                    extra: Default::default(),
1607                }),
1608                ..ProviderConfigs::default()
1609            },
1610        };
1611
1612        let result = get_default_model_for_provider(&config, "openai").expect("openai config");
1613        assert_eq!(result, "gpt-4o");
1614    }
1615
1616    #[test]
1617    fn test_get_fast_model_for_specific_provider() {
1618        let config = test_config! {
1619            provider: "anthropic".to_string(),
1620            providers: ProviderConfigs {
1621                openai: Some(OpenAIConfig {
1622                    api_key: "test".to_string(),
1623                    api_key_from_env: false,
1624                    api_key_encrypted: None,
1625                    credential_ref: None,
1626                    base_url: None,
1627                    model: Some("gpt-4o".to_string()),
1628                    fast_model: Some("gpt-4o-mini".to_string()),
1629                    vision_model: None,
1630                    reasoning_effort: Some(ReasoningEffort::Medium),
1631                    responses_only_models: vec![],
1632                    request_overrides: None,
1633                    extra: Default::default(),
1634                }),
1635                ..ProviderConfigs::default()
1636            },
1637        };
1638
1639        assert_eq!(
1640            get_fast_model_for_provider(&config, "openai").as_deref(),
1641            Some("gpt-4o-mini")
1642        );
1643    }
1644
1645    #[test]
1646    fn test_get_schedule_model_from_config_prefers_fast_model() {
1647        let config = test_config! {
1648            provider: "openai".to_string(),
1649            defaults: None,
1650            features: bamboo_config::FeatureFlags {
1651                provider_model_ref: false,
1652                ..Default::default()
1653            },
1654            providers: ProviderConfigs {
1655                openai: Some(OpenAIConfig {
1656                    api_key: "test".to_string(),
1657                    api_key_from_env: false,
1658                    api_key_encrypted: None,
1659                    credential_ref: None,
1660                    base_url: None,
1661                    model: Some("gpt-4o".to_string()),
1662                    fast_model: Some("gpt-4o-mini".to_string()),
1663                    vision_model: None,
1664                    reasoning_effort: None,
1665                    responses_only_models: vec![],
1666                    request_overrides: None,
1667                    extra: Default::default(),
1668                }),
1669                ..ProviderConfigs::default()
1670            },
1671        };
1672
1673        let result = get_schedule_model_from_config(&config);
1674        assert!(result.is_ok());
1675        assert_eq!(result.unwrap(), "gpt-4o-mini");
1676    }
1677
1678    #[test]
1679    fn test_get_schedule_model_from_config_falls_back_to_default_model() {
1680        let config = test_config! {
1681            provider: "openai".to_string(),
1682            defaults: None,
1683            features: bamboo_config::FeatureFlags {
1684                provider_model_ref: false,
1685                ..Default::default()
1686            },
1687            providers: ProviderConfigs {
1688                openai: Some(OpenAIConfig {
1689                    api_key: "test".to_string(),
1690                    api_key_from_env: false,
1691                    api_key_encrypted: None,
1692                    credential_ref: None,
1693                    base_url: None,
1694                    model: Some("gpt-4o".to_string()),
1695                    fast_model: None,
1696                    vision_model: None,
1697                    reasoning_effort: None,
1698                    responses_only_models: vec![],
1699                    request_overrides: None,
1700                    extra: Default::default(),
1701                }),
1702                ..ProviderConfigs::default()
1703            },
1704        };
1705
1706        let result = get_schedule_model_from_config(&config);
1707        assert!(result.is_ok());
1708        assert_eq!(result.unwrap(), "gpt-4o");
1709    }
1710
1711    #[test]
1712    fn test_get_schedule_model_from_config_prefers_defaults_fast_over_chat() {
1713        let config = test_config! {
1714            provider: "openai".to_string(),
1715            features: bamboo_config::FeatureFlags {
1716                provider_model_ref: true,
1717                ..Default::default()
1718            },
1719            defaults: Some(DefaultsConfig {
1720                chat: ProviderModelRef::new("openai", "gpt-chat"),
1721                fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
1722                task_summary: None,
1723                vision: None,
1724                memory_background: None,
1725                planning: None,
1726                search: None,
1727                code_review: None,
1728                sub_agent: None,
1729                subagent_models: HashMap::new(),
1730            }),
1731        };
1732
1733        let result = get_schedule_model_from_config(&config);
1734        assert!(result.is_ok());
1735        assert_eq!(result.unwrap(), "gpt-fast");
1736    }
1737
1738    #[test]
1739    fn test_get_reasoning_effort_for_specific_provider() {
1740        let config = test_config! {
1741            provider: "anthropic".to_string(),
1742            providers: ProviderConfigs {
1743                openai: Some(OpenAIConfig {
1744                    api_key: "test".to_string(),
1745                    api_key_from_env: false,
1746                    api_key_encrypted: None,
1747                    credential_ref: None,
1748                    base_url: None,
1749                    model: Some("gpt-4o".to_string()),
1750                    fast_model: Some("gpt-4o-mini".to_string()),
1751                    vision_model: None,
1752                    reasoning_effort: Some(ReasoningEffort::Medium),
1753                    responses_only_models: vec![],
1754                    request_overrides: None,
1755                    extra: Default::default(),
1756                }),
1757                ..ProviderConfigs::default()
1758            },
1759        };
1760
1761        assert_eq!(
1762            get_reasoning_effort_for_provider(&config, "openai"),
1763            Some(ReasoningEffort::Medium)
1764        );
1765    }
1766    #[test]
1767    fn resolve_subagent_model_ref_prefers_sub_agent_over_fast() {
1768        let config = test_config! {
1769            provider: "openai".to_string(),
1770            features: bamboo_config::FeatureFlags {
1771                provider_model_ref: true,
1772                ..Default::default()
1773            },
1774            defaults: Some(DefaultsConfig {
1775                chat: ProviderModelRef::new("openai", "gpt-chat"),
1776                fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
1777                task_summary: None,
1778                vision: None,
1779                memory_background: None,
1780                planning: None,
1781                search: None,
1782                code_review: None,
1783                sub_agent: Some(ProviderModelRef::new("openai", "gpt-sub-agent")),
1784                subagent_models: HashMap::new(),
1785            }),
1786        };
1787
1788        let resolved = resolve_subagent_model_ref(&config, "openai", &test_registry(), "coder")
1789            .expect("sub-agent model should resolve");
1790
1791        assert_eq!(resolved, ProviderModelRef::new("openai", "gpt-sub-agent"));
1792    }
1793
1794    #[test]
1795    fn resolve_subagent_model_ref_falls_back_to_fast_when_sub_agent_unset() {
1796        let config = test_config! {
1797            provider: "openai".to_string(),
1798            features: bamboo_config::FeatureFlags {
1799                provider_model_ref: true,
1800                ..Default::default()
1801            },
1802            defaults: Some(DefaultsConfig {
1803                chat: ProviderModelRef::new("openai", "gpt-chat"),
1804                fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
1805                task_summary: None,
1806                vision: None,
1807                memory_background: None,
1808                planning: None,
1809                search: None,
1810                code_review: None,
1811                sub_agent: None,
1812                subagent_models: HashMap::new(),
1813            }),
1814        };
1815
1816        let resolved = resolve_subagent_model_ref(&config, "openai", &test_registry(), "coder")
1817            .expect("fast model should resolve");
1818
1819        assert_eq!(resolved, ProviderModelRef::new("openai", "gpt-fast"));
1820    }
1821
1822    #[test]
1823    fn every_unset_specialist_role_falls_back_to_fast_before_chat() {
1824        let config = test_config! {
1825            provider: "openai".to_string(),
1826            features: bamboo_config::FeatureFlags {
1827                provider_model_ref: true,
1828                ..Default::default()
1829            },
1830            defaults: Some(DefaultsConfig {
1831                chat: ProviderModelRef::new("openai", "gpt-chat"),
1832                fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
1833                task_summary: None,
1834                vision: None,
1835                memory_background: None,
1836                planning: None,
1837                search: None,
1838                code_review: None,
1839                sub_agent: None,
1840                subagent_models: HashMap::new(),
1841            }),
1842        };
1843        let registry = test_registry();
1844
1845        for resolved in [
1846            resolve_task_summary_model(&config, "openai", &registry),
1847            resolve_background_model(&config, "openai", &registry),
1848            resolve_vision_model(&config, "openai", &registry),
1849            resolve_planning_model(&config, "openai", &registry),
1850            resolve_search_model(&config, "openai", &registry),
1851            resolve_code_review_model(&config, "openai", &registry),
1852            resolve_subagent_model(&config, "openai", &registry, "coder"),
1853        ] {
1854            assert_eq!(
1855                resolved.map(|model| model.model_name),
1856                Some("gpt-fast".to_string())
1857            );
1858        }
1859    }
1860
1861    #[test]
1862    fn every_unset_specialist_role_falls_back_to_chat_when_fast_is_unset() {
1863        let config = test_config! {
1864            provider: "openai".to_string(),
1865            features: bamboo_config::FeatureFlags {
1866                provider_model_ref: true,
1867                ..Default::default()
1868            },
1869            defaults: Some(DefaultsConfig {
1870                chat: ProviderModelRef::new("openai", "gpt-chat"),
1871                fast: None,
1872                task_summary: None,
1873                vision: None,
1874                memory_background: None,
1875                planning: None,
1876                search: None,
1877                code_review: None,
1878                sub_agent: None,
1879                subagent_models: HashMap::new(),
1880            }),
1881        };
1882        let registry = test_registry();
1883
1884        for resolved in [
1885            resolve_task_summary_model(&config, "openai", &registry),
1886            resolve_background_model(&config, "openai", &registry),
1887            resolve_vision_model(&config, "openai", &registry),
1888            resolve_planning_model(&config, "openai", &registry),
1889            resolve_search_model(&config, "openai", &registry),
1890            resolve_code_review_model(&config, "openai", &registry),
1891            resolve_subagent_model(&config, "openai", &registry, "coder"),
1892        ] {
1893            assert_eq!(
1894                resolved.map(|model| model.model_name),
1895                Some("gpt-chat".to_string())
1896            );
1897        }
1898    }
1899
1900    // ---- S-T1.1: infer_provider mapping ----
1901
1902    #[test]
1903    fn infer_provider_maps_known_families() {
1904        // claude* → anthropic
1905        assert_eq!(
1906            infer_provider("claude-3-7-sonnet").as_deref(),
1907            Some("anthropic")
1908        );
1909        assert_eq!(infer_provider("Claude-Opus").as_deref(), Some("anthropic"));
1910        // gpt* → openai
1911        assert_eq!(infer_provider("gpt-4o").as_deref(), Some("openai"));
1912        assert_eq!(infer_provider("GPT-4o-mini").as_deref(), Some("openai"));
1913        // o-series → openai
1914        assert_eq!(infer_provider("o1").as_deref(), Some("openai"));
1915        assert_eq!(infer_provider("o3-mini").as_deref(), Some("openai"));
1916        assert_eq!(infer_provider("o4-mini").as_deref(), Some("openai"));
1917        // gemini* → gemini
1918        assert_eq!(infer_provider("gemini-1.5-pro").as_deref(), Some("gemini"));
1919        // unknown / empty → None
1920        assert_eq!(infer_provider("llama-3"), None);
1921        assert_eq!(infer_provider("opus"), None); // no o-digit, not claude*
1922        assert_eq!(infer_provider("  "), None);
1923        assert_eq!(infer_provider(""), None);
1924    }
1925}