1use 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
13pub 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
26pub 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
57fn 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
65pub 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
94pub 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 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
205pub 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
279pub fn get_default_model_from_config(config: &Config) -> Result<String, LLMError> {
282 get_default_model_for_provider(config, config.effective_default_provider())
283}
284
285pub 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
302pub 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
344pub 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
363pub 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
377pub 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
389pub 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
402pub 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 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
493pub 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 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
569pub 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
586pub 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
605pub 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 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
670struct 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#[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
784pub 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
830pub 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
865pub 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
887pub 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
909pub 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
931pub 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 return Some(model_ref.clone());
954 }
955
956 let legacy_model = if let Some(instance) = config.provider_instances.get(provider_name) {
957 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 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
980pub 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
995pub 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#[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#[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", ®istry)
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", ®istry)
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", ®istry).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", ®istry)
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 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, ®istry, &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 ®istry.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, ®istry, &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", ®istry).unwrap(),
1373 "work-openai"
1374 );
1375 assert_eq!(
1376 resolve_provider_routing_key(&config, "anthropic", ®istry).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", ®istry)
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", ®istry).unwrap(),
1412 "a-openai"
1413 );
1414 assert!(resolve_provider_routing_key(&config, "typo", ®istry).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, 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", ®istry),
1847 resolve_background_model(&config, "openai", ®istry),
1848 resolve_vision_model(&config, "openai", ®istry),
1849 resolve_planning_model(&config, "openai", ®istry),
1850 resolve_search_model(&config, "openai", ®istry),
1851 resolve_code_review_model(&config, "openai", ®istry),
1852 resolve_subagent_model(&config, "openai", ®istry, "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", ®istry),
1886 resolve_background_model(&config, "openai", ®istry),
1887 resolve_vision_model(&config, "openai", ®istry),
1888 resolve_planning_model(&config, "openai", ®istry),
1889 resolve_search_model(&config, "openai", ®istry),
1890 resolve_code_review_model(&config, "openai", ®istry),
1891 resolve_subagent_model(&config, "openai", ®istry, "coder"),
1892 ] {
1893 assert_eq!(
1894 resolved.map(|model| model.model_name),
1895 Some("gpt-chat".to_string())
1896 );
1897 }
1898 }
1899
1900 #[test]
1903 fn infer_provider_maps_known_families() {
1904 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 assert_eq!(infer_provider("gpt-4o").as_deref(), Some("openai"));
1912 assert_eq!(infer_provider("GPT-4o-mini").as_deref(), Some("openai"));
1913 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 assert_eq!(infer_provider("gemini-1.5-pro").as_deref(), Some("gemini"));
1919 assert_eq!(infer_provider("llama-3"), None);
1921 assert_eq!(infer_provider("opus"), None); assert_eq!(infer_provider(" "), None);
1923 assert_eq!(infer_provider(""), None);
1924 }
1925}