1use std::collections::HashMap;
4
5pub fn min_healthy_agents_threshold() -> usize {
8 crate::config::get("dispatch.min_healthy_agents")
9 .ok()
10 .and_then(|v| v.parse().ok())
11 .unwrap_or(2)
12}
13
14pub fn sequential_dispatch_delay_ms() -> u64 {
16 crate::config::get("dispatch.sequential_delay_ms")
17 .ok()
18 .and_then(|v| v.parse().ok())
19 .unwrap_or(1000)
20}
21
22pub fn health_check_window_hours() -> u32 {
27 crate::config::get("dispatch.health_check_window_hours")
28 .ok()
29 .and_then(|v| v.parse().ok())
30 .unwrap_or(crate::engine::cooldown::DEFAULT_HEALTH_CHECK_WINDOW_HOURS)
31}
32
33pub fn degraded_rate_limit_threshold() -> i64 {
47 crate::config::get("dispatch.degraded_rate_limit_threshold")
48 .ok()
49 .and_then(|v| v.parse().ok())
50 .unwrap_or(crate::engine::cooldown::DEFAULT_DEGRADED_RATE_LIMIT_THRESHOLD)
51}
52
53pub fn retry_base_delay_ms() -> u64 {
55 crate::config::get("dispatch.retry_base_delay_ms")
56 .ok()
57 .and_then(|v| v.parse().ok())
58 .unwrap_or(10_000)
59}
60
61pub fn retry_max_delay_ms() -> u64 {
63 crate::config::get("dispatch.retry_max_delay_ms")
64 .ok()
65 .and_then(|v| v.parse().ok())
66 .unwrap_or(120_000)
67}
68
69pub const MAX_ROUTER_TIMEOUT_SECS: u64 = 45;
74
75pub const SKIP_LIMITED_THRESHOLD: f64 = 0.3;
78
79pub fn get_agent_backoff_base(agent: &str) -> i64 {
88 let key = format!("router.backoff_base.{agent}");
89 crate::config::get(&key)
90 .ok()
91 .and_then(|v| v.parse().ok())
92 .unwrap_or(crate::engine::cooldown::BACKOFF_BASE_SECS)
93}
94
95pub fn max_tasks_per_routing_tick() -> usize {
102 crate::config::get("router.max_tasks_per_tick")
103 .ok()
104 .and_then(|v| v.parse().ok())
105 .unwrap_or(1)
106}
107
108pub const DEFAULT_AGENTS: &[&str] = &[
115 "claude", "codex", "opencode", "kimi", "minimax", "glm", "grok",
116];
117
118#[derive(Debug, Clone)]
120pub struct RouterConfig {
121 pub mode: String,
127 pub router_agent: String,
129 pub router_model: String,
131 pub timeout_seconds: u64,
133 pub fallback_executor: String,
135 pub agents: Vec<String>,
137 pub max_route_attempts: u32,
139 pub allowed_tools: Vec<String>,
141 pub default_skills: Vec<String>,
143 pub model_map: HashMap<String, HashMap<String, Vec<String>>>,
147 pub weighted_round_robin: bool,
150 pub pool: Vec<String>,
154 pub fallback: String,
157 pub self_routing_penalty: f64,
165 pub weights: HashMap<String, f64>,
183 pub agent_backoff_bases: HashMap<String, i64>,
190 pub max_tasks_per_tick: usize,
197 pub ollama_url: String,
199 pub ollama_model: String,
201 pub ollama_timeout_seconds: u64,
203}
204
205pub fn parse_pool_entry(entry: &str) -> (String, String) {
212 if let Some(colon_pos) = entry.find(':') {
213 let agent = entry[..colon_pos].to_string();
214 let model = entry[colon_pos + 1..].to_string();
215 let model = RouterConfig::normalize_model_identifier(&model);
217 (agent, model)
218 } else {
219 (entry.to_string(), String::new())
220 }
221}
222
223impl Default for RouterConfig {
224 fn default() -> Self {
225 Self {
226 mode: "round_robin".to_string(),
227 router_agent: "claude".to_string(),
228 router_model: "haiku".to_string(),
229 timeout_seconds: 45,
230 fallback_executor: "codex".to_string(),
231 agents: DEFAULT_AGENTS
232 .iter()
233 .map(std::string::ToString::to_string)
234 .collect(),
235 max_route_attempts: 3,
236 pool: vec![],
237 fallback: String::new(),
238 self_routing_penalty: 1.0,
239 weights: HashMap::new(),
240 allowed_tools: vec![
241 "yq".to_string(),
242 "jq".to_string(),
243 "bash".to_string(),
244 "just".to_string(),
245 "git".to_string(),
246 "rg".to_string(),
247 "sed".to_string(),
248 "awk".to_string(),
249 "python3".to_string(),
250 "node".to_string(),
251 "npm".to_string(),
252 "bun".to_string(),
253 ],
254 default_skills: vec!["gh".to_string(), "git-worktree".to_string()],
255 model_map: HashMap::new(),
259 weighted_round_robin: false,
260 agent_backoff_bases: HashMap::new(),
261 max_tasks_per_tick: 1,
262 ollama_url: "http://localhost:11434".to_string(),
263 ollama_model: "qwen2.5-coder:3b-instruct".to_string(),
264 ollama_timeout_seconds: 30,
265 }
266 }
267}
268
269impl RouterConfig {
270 pub fn from_config() -> Self {
272 let mut config = Self::default();
273
274 if let Ok(mode) = crate::config::get("router.mode")
276 && (mode == "round_robin" || mode == "llm" || mode == "local")
277 {
278 config.mode = mode;
279 }
280
281 if let Ok(agent) = crate::config::get("router.agent")
282 && !agent.is_empty()
283 {
284 config.router_agent = agent;
285 }
286
287 if let Ok(model) = crate::config::get("router.model")
288 && !model.is_empty()
289 {
290 config.router_model = RouterConfig::normalize_model_identifier(&model);
291 }
292
293 if let Ok(timeout) = crate::config::get("router.timeout_seconds")
294 && let Ok(secs) = timeout.parse::<u64>()
295 {
296 let clamped = secs.min(MAX_ROUTER_TIMEOUT_SECS);
297 if clamped != secs {
298 static TIMEOUT_CLAMP_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
299 TIMEOUT_CLAMP_WARNED.get_or_init(|| {
300 tracing::warn!(
301 configured_secs = secs,
302 applied_secs = clamped,
303 max_secs = MAX_ROUTER_TIMEOUT_SECS,
304 "router.timeout_seconds is too high; clamping to keep routing responsive"
305 );
306 });
307 }
308 config.timeout_seconds = clamped;
309 }
310
311 if let Ok(fallback) = crate::config::get("router.fallback_executor")
312 && !fallback.is_empty()
313 {
314 config.fallback_executor = fallback;
315 }
316
317 config.agents = crate::engine::configured_agents();
319
320 let exclude_agents = std::env::var("ORCH_EXCLUDE_AGENTS")
326 .map(|v| {
327 v.split(',')
328 .map(|s| s.trim().to_string())
329 .filter(|s| !s.is_empty())
330 .collect::<Vec<_>>()
331 })
332 .unwrap_or_default();
333 if !exclude_agents.is_empty() {
334 let original_len = config.agents.len();
335 config.agents.retain(|a| !exclude_agents.contains(a));
336 let removed_count = original_len - config.agents.len();
337 if removed_count > 0 {
338 tracing::warn!(
339 "ORCH_EXCLUDE_AGENTS={}: temporarily removing {} agent(s) from router",
340 exclude_agents.join(","),
341 removed_count
342 );
343 }
344 }
345
346 if let Ok(max_attempts) = crate::config::get("router.max_route_attempts")
347 && let Ok(n) = max_attempts.parse::<u32>()
348 {
349 config.max_route_attempts = n;
350 }
351
352 if let Ok(tools_str) = crate::config::get("router.allowed_tools")
354 && !tools_str.is_empty()
355 && tools_str != "[]"
356 {
357 if let Ok(tools_arr) = serde_json::from_str::<Vec<String>>(&tools_str) {
359 config.allowed_tools = tools_arr;
360 } else {
361 config.allowed_tools = tools_str
363 .split(',')
364 .map(|s| s.trim().to_string())
365 .filter(|s| !s.is_empty())
366 .collect();
367 }
368 }
369
370 if let Ok(val) = crate::config::get("router.weighted_round_robin") {
372 config.weighted_round_robin = val == "true" || val == "1";
373 }
374
375 if let Ok(val) = crate::config::get("router.self_routing_penalty")
377 && let Ok(penalty) = val.parse::<f64>()
378 {
379 config.self_routing_penalty = penalty.clamp(0.0, 1.0);
380 }
381
382 for agent in &config.agents {
384 let key = format!("router.weights.{agent}");
385 if let Ok(val) = crate::config::get(&key)
386 && let Ok(w) = val.parse::<f64>()
387 {
388 config.weights.insert(agent.clone(), w.max(0.0));
389 }
390 }
391
392 for agent in &config.agents {
394 let key = format!("router.backoff_base.{agent}");
395 if let Ok(val) = crate::config::get(&key)
396 && let Ok(secs) = val.parse::<i64>()
397 {
398 config.agent_backoff_bases.insert(agent.clone(), secs);
399 }
400 }
401
402 if let Ok(val) = crate::config::get("router.max_tasks_per_tick")
404 && let Ok(n) = val.parse::<usize>()
405 && n > 0
406 {
407 config.max_tasks_per_tick = n;
408 }
409
410 match crate::config::get_list("router.pool") {
414 Ok(pool_list) if !pool_list.is_empty() => {
415 config.pool = pool_list;
416 }
417 _ => {
418 if let Ok(pool_raw) = crate::config::get("router.pool") {
420 let parsed = pool_raw
421 .split(',')
422 .map(|s| s.trim().to_string())
423 .filter(|s| !s.is_empty())
424 .collect::<Vec<_>>();
425 if !parsed.is_empty() {
426 config.pool = parsed;
427 }
428 }
429 }
430 }
431
432 if let Ok(fallback) = crate::config::get("router.fallback")
434 && !fallback.is_empty()
435 {
436 config.fallback = fallback;
437 }
438
439 let known_agents = crate::engine::configured_agents();
445 let known_complexities = ["simple", "medium", "complex", "review"];
446 for agent in &known_agents {
447 let key = format!("models.{agent}");
448 if let Some(models) = Self::read_model_pool_from_config(&key) {
449 for complexity in known_complexities {
450 config
451 .model_map
452 .entry(complexity.to_string())
453 .or_default()
454 .insert(agent.clone(), models.clone());
455 }
456 }
457 }
458
459 for complexity in known_complexities {
464 for agent in &known_agents {
465 let key = format!("model_map.{complexity}.{agent}");
466 if let Some(models) = Self::read_model_pool_from_config(&key) {
467 config
468 .model_map
469 .entry(complexity.to_string())
470 .or_default()
471 .insert(agent.clone(), models);
472 }
473 }
474 }
475
476 let discovered_opencode = if config.uses_opencode() {
484 crate::engine::runner::agents::opencode::discover_opencode_models()
485 } else {
486 Vec::new()
487 };
488 if !discovered_opencode.is_empty() {
489 let discovered: std::collections::HashSet<String> =
490 discovered_opencode.into_iter().collect();
491 for complexity in known_complexities {
492 if let Some(agent_models) = config.model_map.get(complexity)
493 && let Some(models) = agent_models.get("opencode")
494 {
495 for model in models {
496 if model != "opencode:free" && !discovered.contains(model) {
497 tracing::warn!(
498 complexity,
499 model,
500 "opencode model from config not present in provider model list; pruning from dispatch pool"
501 );
502 }
503 }
504 }
505 }
506 let pruned =
507 Self::prune_unavailable_opencode_models(&mut config.model_map, &discovered);
508 if pruned > 0 {
509 tracing::info!(
510 pruned_count = pruned,
511 "model_pruned_unavailable: removed opencode models absent from provider catalog"
512 );
513 }
514 }
515
516 if let Ok(skills_str) = crate::config::get("router.default_skills")
518 && !skills_str.is_empty()
519 && skills_str != "[]"
520 {
521 if let Ok(skills_arr) = serde_json::from_str::<Vec<String>>(&skills_str) {
522 config.default_skills = skills_arr;
523 } else {
524 config.default_skills = skills_str
525 .split(',')
526 .map(|s| s.trim().to_string())
527 .filter(|s| !s.is_empty())
528 .collect();
529 }
530 }
531
532 if let Ok(url) = crate::config::get("router.ollama_url")
534 && !url.is_empty()
535 {
536 config.ollama_url = url;
537 }
538 if let Ok(model) = crate::config::get("router.ollama_model")
539 && !model.is_empty()
540 {
541 config.ollama_model = model;
542 }
543 if let Ok(timeout) = crate::config::get("router.ollama_timeout_seconds")
544 && let Ok(secs) = timeout.parse::<u64>()
545 && secs > 0
546 {
547 config.ollama_timeout_seconds = secs;
548 }
549
550 config
551 }
552
553 pub fn effective_pool(&self) -> Vec<String> {
555 if self.pool.is_empty() {
556 vec![format!("{}:{}", self.router_agent, self.router_model)]
557 } else {
558 self.pool.clone()
559 }
560 }
561
562 pub fn effective_fallback(&self) -> String {
564 if self.fallback.is_empty() {
565 format!("{}:{}", self.router_agent, self.router_model)
566 } else {
567 self.fallback.clone()
568 }
569 }
570
571 fn is_valid_model_identifier(model: &str) -> bool {
574 if model.is_empty() {
575 return false;
576 }
577 if model.ends_with('/') {
579 return false;
580 }
581 if model.chars().any(char::is_whitespace) {
583 return false;
584 }
585 if let Some(slash_pos) = model.find('/') {
587 let before = &model[..slash_pos];
588 let after = &model[slash_pos + 1..];
589 if before.is_empty() || after.is_empty() {
590 return false;
591 }
592 }
593 true
594 }
595
596 pub fn normalize_model_identifier(model: &str) -> String {
600 model.trim_end_matches('/').to_string()
601 }
602
603 fn read_model_pool_from_config(key: &str) -> Option<Vec<String>> {
604 match crate::config::get_list(key) {
605 Ok(list) => {
606 if !list.is_empty() {
607 return Some(
608 list.iter()
609 .map(|m| RouterConfig::normalize_model_identifier(m))
610 .collect(),
611 );
612 }
613 }
614 _ => {
615 if let Ok(val) = crate::config::get(key)
616 && !val.is_empty()
617 {
618 return Some(vec![RouterConfig::normalize_model_identifier(&val)]);
619 }
620 }
621 }
622
623 None
624 }
625
626 fn expanded_model_pool(&self, agent: &str, complexity: &str) -> Option<Vec<String>> {
627 let pool = self.model_map.get(complexity)?.get(agent)?;
628 if pool.is_empty() {
629 return Some(Vec::new());
630 }
631
632 let mut expanded_pool = Vec::new();
633 let mut has_free = false;
634
635 for model in pool {
636 let normalized = Self::normalize_model_identifier(model);
638 if normalized == "opencode:free" {
639 has_free = true;
640 } else if Self::is_valid_model_identifier(&normalized) {
641 expanded_pool.push(normalized);
642 } else {
643 tracing::debug!(agent, model, "skipping invalid model identifier in config");
644 }
645 }
646
647 if has_free {
648 expanded_pool
649 .extend(crate::engine::runner::agents::opencode::discover_free_opencode_models());
650 }
651
652 expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
654
655 if agent == "opencode" {
658 let discovered = crate::engine::runner::agents::opencode::discover_opencode_models();
659 if !discovered.is_empty() {
660 let discovered_set: std::collections::HashSet<String> =
661 discovered.into_iter().collect();
662 expanded_pool.retain(|m| discovered_set.contains(m));
663 }
664 }
665
666 Some(expanded_pool)
667 }
668
669 pub(crate) fn model_pool_for_complexity(
670 &self,
671 agent: &str,
672 complexity: &str,
673 ) -> Option<Vec<String>> {
674 self.expanded_model_pool(agent, complexity)
675 }
676
677 pub fn has_available_model_for_complexity(&self, agent: &str, complexity: &str) -> bool {
678 match self.expanded_model_pool(agent, complexity) {
679 None => false,
680 Some(pool) => pool
681 .iter()
682 .any(|model| !crate::engine::cooldown::is_model_in_cooldown(agent, model)),
683 }
684 }
685
686 pub fn model_for_complexity(
694 &self,
695 agent: &str,
696 complexity: &str,
697 task_id: &str,
698 ) -> Option<String> {
699 let expanded_pool = self.expanded_model_pool(agent, complexity)?;
700 if expanded_pool.is_empty() {
701 return None;
702 }
703 let start =
705 crate::engine::router::selection::simple_hash_index_for(expanded_pool.len(), task_id);
706 for i in 0..expanded_pool.len() {
708 let model = &expanded_pool[(start + i) % expanded_pool.len()];
709 if !Self::is_valid_model_identifier(model) {
711 tracing::debug!(
712 agent,
713 model,
714 "invalid model identifier slipped through validation"
715 );
716 continue;
717 }
718 if !crate::engine::cooldown::is_model_in_cooldown(agent, model) {
719 return Some(Self::normalize_model_identifier(model));
721 }
722 }
723 None
725 }
726
727 pub(crate) fn uses_opencode(&self) -> bool {
731 fn names_opencode(entry: &str) -> bool {
732 entry == "opencode" || entry.starts_with("opencode:")
733 }
734
735 if self
736 .model_map
737 .values()
738 .any(|by_agent| by_agent.contains_key("opencode"))
739 {
740 return true;
741 }
742
743 self.effective_pool().iter().any(|e| names_opencode(e))
744 || names_opencode(&self.effective_fallback())
745 }
746
747 fn prune_unavailable_opencode_models(
753 model_map: &mut HashMap<String, HashMap<String, Vec<String>>>,
754 discovered: &std::collections::HashSet<String>,
755 ) -> usize {
756 let mut pruned = 0;
757 for agent_models in model_map.values_mut() {
758 if let Some(models) = agent_models.get_mut("opencode") {
759 let before = models.len();
760 models.retain(|m| m == "opencode:free" || discovered.contains(m));
761 pruned += before - models.len();
762 }
763 }
764 pruned
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::{MAX_ROUTER_TIMEOUT_SECS, RouterConfig};
771 use serial_test::serial;
772 use std::sync::{Mutex, OnceLock};
773
774 struct CurrentDirGuard {
775 original: std::path::PathBuf,
776 }
777
778 impl CurrentDirGuard {
779 fn set(path: &std::path::Path) -> Self {
780 let original = std::env::current_dir().unwrap();
781 std::env::set_current_dir(path).unwrap();
782 Self { original }
783 }
784 }
785
786 impl Drop for CurrentDirGuard {
787 fn drop(&mut self) {
788 std::env::set_current_dir(&self.original).unwrap();
789 }
790 }
791
792 fn cwd_mutex() -> &'static Mutex<()> {
793 static CWD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
794 CWD_MUTEX.get_or_init(|| Mutex::new(()))
795 }
796
797 #[serial(cooldown_state)]
798 #[tokio::test]
799 async fn model_for_complexity_returns_none_when_all_models_cooled() {
800 crate::engine::cooldown::reset_global_state().await;
801 let mut config = RouterConfig::default();
802 config
804 .model_map
805 .entry("complex".to_string())
806 .or_default()
807 .insert(
808 "testagent_allcooled".to_string(),
809 vec![
810 "cooldown-model-a".to_string(),
811 "cooldown-model-b".to_string(),
812 ],
813 );
814
815 crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-a")
817 .await;
818 crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-b")
819 .await;
820
821 let result = config.model_for_complexity("testagent_allcooled", "complex", "task-0");
822 assert!(
823 result.is_none(),
824 "should return None when all models in pool are in cooldown, got {result:?}"
825 );
826 }
827
828 #[serial(cooldown_state)]
829 #[tokio::test]
830 async fn model_for_complexity_returns_none_when_single_model_cooled() {
831 crate::engine::cooldown::reset_global_state().await;
832 let mut config = RouterConfig::default();
833 config
834 .model_map
835 .entry("complex".to_string())
836 .or_default()
837 .insert(
838 "testagent_singlecooled".to_string(),
839 vec!["single-cooled-model".to_string()],
840 );
841
842 crate::engine::cooldown::record_model_failure(
843 "testagent_singlecooled",
844 "single-cooled-model",
845 )
846 .await;
847
848 let result = config.model_for_complexity("testagent_singlecooled", "complex", "task-0");
849 assert!(
850 result.is_none(),
851 "should return None when the only model in pool is in cooldown, got {result:?}"
852 );
853 }
854
855 #[serial(cooldown_state)]
856 #[test]
857 fn model_for_complexity_returns_model_when_not_cooled() {
858 let mut config = RouterConfig::default();
859 config
860 .model_map
861 .entry("complex".to_string())
862 .or_default()
863 .insert(
864 "testagent_notcooled".to_string(),
865 vec!["healthy-model".to_string()],
866 );
867
868 let result = config.model_for_complexity("testagent_notcooled", "complex", "task-0");
870 assert_eq!(
871 result.as_deref(),
872 Some("healthy-model"),
873 "should return the model when it is not in cooldown"
874 );
875 }
876
877 #[serial(cooldown_state)]
878 #[test]
879 fn has_available_model_returns_false_when_no_pool_configured() {
880 let mut config = RouterConfig::default();
881 config
883 .model_map
884 .entry("simple".to_string())
885 .or_default()
886 .insert("olm".to_string(), vec!["qwen3.5".to_string()]);
887 config
888 .model_map
889 .entry("medium".to_string())
890 .or_default()
891 .insert("olm".to_string(), vec!["gemma4".to_string()]);
892 assert!(
896 config.has_available_model_for_complexity("olm", "simple"),
897 "should return true when model pool exists and is configured"
898 );
899 assert!(
900 config.has_available_model_for_complexity("olm", "medium"),
901 "should return true when model pool exists and is configured"
902 );
903 assert!(
904 !config.has_available_model_for_complexity("olm", "complex"),
905 "should return false when no model pool is configured for agent+complexity"
906 );
907 assert!(
908 !config.has_available_model_for_complexity("olm", "review"),
909 "should return false when no model pool is configured for agent+complexity"
910 );
911 }
912
913 #[serial(cooldown_state)]
914 #[test]
915 fn default_self_routing_penalty_is_one() {
916 let config = RouterConfig::default();
917 assert!(
918 (config.self_routing_penalty - 1.0).abs() < f64::EPSILON,
919 "default penalty must be 1.0 (no penalty = full self-routing allowed)"
920 );
921 }
922
923 #[serial(cooldown_state)]
924 #[test]
925 fn from_config_reads_self_routing_penalty() {
926 let _lock = cwd_mutex().lock().unwrap();
927 let dir = tempfile::tempdir().unwrap();
928 std::fs::write(
929 dir.path().join(".orch.yml"),
930 "router:\n self_routing_penalty: 0.5\n",
931 )
932 .unwrap();
933 let _guard = CurrentDirGuard::set(dir.path());
934
935 let config = RouterConfig::from_config();
936 assert!((config.self_routing_penalty - 0.5).abs() < f64::EPSILON);
937 }
938
939 #[serial(cooldown_state)]
940 #[test]
941 fn default_config_model_map_is_empty() {
942 let config = RouterConfig::default();
946 assert!(
947 config.model_map.is_empty(),
948 "Default RouterConfig must have no hardcoded models; got {:?}",
949 config.model_map
950 );
951 }
952
953 #[serial(cooldown_state)]
954 #[test]
955 fn from_config_reads_router_pool_yaml_array() {
956 let _lock = cwd_mutex().lock().unwrap();
957 let dir = tempfile::tempdir().unwrap();
958 std::fs::write(
959 dir.path().join(".orch.yml"),
960 "router:\n pool:\n - opencode:free\n - opencode:github-copilot/gpt-5-mini\n - kimi:k2p5\n - claude:haiku\n",
961 )
962 .unwrap();
963 let _guard = CurrentDirGuard::set(dir.path());
964
965 let config = RouterConfig::from_config();
966
967 assert_eq!(
968 config.pool,
969 vec![
970 "opencode:free",
971 "opencode:github-copilot/gpt-5-mini",
972 "kimi:k2p5",
973 "claude:haiku",
974 ]
975 );
976 }
977
978 #[serial(cooldown_state)]
979 #[test]
980 fn from_config_clamps_router_timeout_to_max() {
981 let _lock = cwd_mutex().lock().unwrap();
982 let dir = tempfile::tempdir().unwrap();
983 std::fs::write(
984 dir.path().join(".orch.yml"),
985 "router:\n timeout_seconds: 90\n",
986 )
987 .unwrap();
988 let _guard = CurrentDirGuard::set(dir.path());
989
990 let config = RouterConfig::from_config();
991 assert_eq!(config.timeout_seconds, MAX_ROUTER_TIMEOUT_SECS);
992 }
993
994 #[serial(cooldown_state)]
995 #[test]
996 fn normalize_model_identifier_strips_trailing_slash() {
997 assert_eq!(
999 RouterConfig::normalize_model_identifier("opus/"),
1000 "opus",
1001 "trailing slash should be stripped from model identifier"
1002 );
1003 assert_eq!(
1004 RouterConfig::normalize_model_identifier("sonnet/"),
1005 "sonnet",
1006 "trailing slash should be stripped from model identifier"
1007 );
1008 assert_eq!(
1009 RouterConfig::normalize_model_identifier("github-copilot/gpt-5-mini/"),
1010 "github-copilot/gpt-5-mini",
1011 "trailing slash should be stripped from model identifier with internal slash"
1012 );
1013 assert_eq!(
1015 RouterConfig::normalize_model_identifier("opus//"),
1016 "opus",
1017 "multiple trailing slashes should be stripped"
1018 );
1019 assert_eq!(
1021 RouterConfig::normalize_model_identifier("opus"),
1022 "opus",
1023 "model without trailing slash should remain unchanged"
1024 );
1025 }
1026
1027 #[serial(cooldown_state)]
1028 #[test]
1029 fn is_valid_model_identifier_rejects_trailing_slash() {
1030 assert!(
1032 !RouterConfig::is_valid_model_identifier("opus/"),
1033 "model with trailing slash should be invalid"
1034 );
1035 assert!(
1036 RouterConfig::is_valid_model_identifier("opus"),
1037 "model without trailing slash should be valid"
1038 );
1039 assert!(
1040 RouterConfig::is_valid_model_identifier("github-copilot/gpt-5-mini"),
1041 "model with internal slash should be valid"
1042 );
1043 assert!(
1044 !RouterConfig::is_valid_model_identifier("github-copilot/"),
1045 "model ending with slash after prefix should be invalid"
1046 );
1047 }
1048
1049 #[serial(cooldown_state)]
1050 #[test]
1051 fn from_config_loads_model_map_for_custom_agents() {
1052 let _lock = cwd_mutex().lock().unwrap();
1053 let dir = tempfile::tempdir().unwrap();
1054 std::fs::write(
1056 dir.path().join(".orch.yml"),
1057 r"
1058agents:
1059 - claude
1060 - olm
1061model_map:
1062 simple:
1063 claude: haiku
1064 olm: qwen3.5
1065 medium:
1066 claude: sonnet
1067 olm: gemma4
1068 complex:
1069 claude: opus
1070 olm: gemma4
1071",
1072 )
1073 .unwrap();
1074 let _guard = CurrentDirGuard::set(dir.path());
1075
1076 let config = RouterConfig::from_config();
1077
1078 assert_eq!(
1080 config.model_for_complexity("olm", "simple", "test-task"),
1081 Some("qwen3.5".to_string()),
1082 "custom agent olm should have simple model from config"
1083 );
1084 assert_eq!(
1085 config.model_for_complexity("olm", "medium", "test-task"),
1086 Some("gemma4".to_string()),
1087 "custom agent olm should have medium model from config"
1088 );
1089 assert_eq!(
1091 config.model_for_complexity("claude", "simple", "test-task"),
1092 Some("haiku".to_string()),
1093 );
1094 }
1095
1096 #[serial(cooldown_state)]
1097 #[test]
1098 fn from_config_loads_models_shorthand_for_all_complexities() {
1099 let _lock = cwd_mutex().lock().unwrap();
1100 let dir = tempfile::tempdir().unwrap();
1101 std::fs::write(
1102 dir.path().join(".orch.yml"),
1103 r"
1104agents:
1105 - shorthand
1106models:
1107 shorthand:
1108 - gpt-5.2
1109 - gpt-5.5
1110",
1111 )
1112 .unwrap();
1113 let _guard = CurrentDirGuard::set(dir.path());
1114
1115 let config = RouterConfig::from_config();
1116
1117 for complexity in ["simple", "medium", "complex", "review"] {
1118 assert_eq!(
1119 config.model_map[complexity]["shorthand"],
1120 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()],
1121 "models.shorthand should populate {complexity}"
1122 );
1123 }
1124 }
1125
1126 #[serial(cooldown_state)]
1127 #[test]
1128 fn from_config_loads_single_model_shorthand_for_all_complexities() {
1129 let _lock = cwd_mutex().lock().unwrap();
1130 let dir = tempfile::tempdir().unwrap();
1131 std::fs::write(
1132 dir.path().join(".orch.yml"),
1133 r"
1134agents:
1135 - shorthand
1136models:
1137 shorthand: gpt-5.2
1138",
1139 )
1140 .unwrap();
1141 let _guard = CurrentDirGuard::set(dir.path());
1142
1143 let config = RouterConfig::from_config();
1144
1145 for complexity in ["simple", "medium", "complex", "review"] {
1146 assert_eq!(
1147 config.model_map[complexity]["shorthand"],
1148 vec!["gpt-5.2".to_string()],
1149 "models.shorthand should populate {complexity}"
1150 );
1151 }
1152 }
1153
1154 #[serial(cooldown_state)]
1155 #[test]
1156 fn from_config_model_map_overrides_models_shorthand_per_tier() {
1157 let _lock = cwd_mutex().lock().unwrap();
1158 let dir = tempfile::tempdir().unwrap();
1159 std::fs::write(
1160 dir.path().join(".orch.yml"),
1161 r"
1162agents:
1163 - shorthand
1164models:
1165 shorthand:
1166 - gpt-5.2
1167 - gpt-5.5
1168model_map:
1169 review:
1170 shorthand: gpt-5.6-review
1171",
1172 )
1173 .unwrap();
1174 let _guard = CurrentDirGuard::set(dir.path());
1175
1176 let config = RouterConfig::from_config();
1177
1178 assert_eq!(
1179 config.model_map["simple"]["shorthand"],
1180 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1181 );
1182 assert_eq!(
1183 config.model_map["medium"]["shorthand"],
1184 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1185 );
1186 assert_eq!(
1187 config.model_map["complex"]["shorthand"],
1188 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1189 );
1190 assert_eq!(
1191 config.model_map["review"]["shorthand"],
1192 vec!["gpt-5.6-review".to_string()]
1193 );
1194 }
1195
1196 #[serial(cooldown_state)]
1197 #[test]
1198 fn model_for_complexity_normalizes_trailing_slash_from_pool() {
1199 let mut config = RouterConfig::default();
1202 config
1203 .model_map
1204 .entry("complex".to_string())
1205 .or_default()
1206 .insert(
1207 "testagent_trailing".to_string(),
1208 vec![
1209 "opus/".to_string(),
1210 "sonnet//".to_string(),
1211 "github-copilot/gpt-5-mini/".to_string(),
1212 ],
1213 );
1214
1215 let result = config.model_for_complexity("testagent_trailing", "complex", "task-0");
1217 assert!(
1218 result.is_some(),
1219 "should return a model from pool with trailing slashes normalized, got None"
1220 );
1221 let model = result.unwrap();
1222 assert!(
1224 model == "opus" || model == "sonnet" || model == "github-copilot/gpt-5-mini",
1225 "returned model should be normalized (no trailing slash), got: {model}"
1226 );
1227 assert!(
1229 !model.ends_with('/'),
1230 "returned model should not have trailing slash, got: {model}"
1231 );
1232 }
1233
1234 #[serial(cooldown_state)]
1237 #[test]
1238 fn prune_unavailable_opencode_models_removes_missing_models() {
1239 use std::collections::{HashMap, HashSet};
1240
1241 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1242 model_map.entry("complex".to_string()).or_default().insert(
1243 "opencode".to_string(),
1244 vec![
1245 "available-model".to_string(),
1246 "unavailable-model".to_string(),
1247 "opencode:free".to_string(), ],
1249 );
1250
1251 let mut discovered = HashSet::new();
1252 discovered.insert("available-model".to_string());
1253 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1256
1257 assert_eq!(pruned, 1, "should prune exactly the one unavailable model");
1258
1259 let remaining = model_map["complex"]["opencode"].as_slice();
1260 assert!(
1261 remaining.contains(&"available-model".to_string()),
1262 "available model should remain"
1263 );
1264 assert!(
1265 remaining.contains(&"opencode:free".to_string()),
1266 "opencode:free sentinel should never be pruned"
1267 );
1268 assert!(
1269 !remaining.contains(&"unavailable-model".to_string()),
1270 "unavailable model should be removed"
1271 );
1272 }
1273
1274 #[serial(cooldown_state)]
1275 #[test]
1276 fn prune_unavailable_opencode_models_noop_when_all_available() {
1277 use std::collections::{HashMap, HashSet};
1278
1279 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1280 model_map.entry("simple".to_string()).or_default().insert(
1281 "opencode".to_string(),
1282 vec!["model-a".to_string(), "model-b".to_string()],
1283 );
1284
1285 let discovered: HashSet<String> = ["model-a", "model-b"]
1286 .iter()
1287 .map(std::string::ToString::to_string)
1288 .collect();
1289
1290 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1291
1292 assert_eq!(pruned, 0, "nothing to prune when all models are available");
1293 assert_eq!(
1294 model_map["simple"]["opencode"].len(),
1295 2,
1296 "both models should remain"
1297 );
1298 }
1299
1300 #[serial(cooldown_state)]
1301 #[test]
1302 fn prune_unavailable_opencode_models_preserves_non_opencode_agents() {
1303 use std::collections::{HashMap, HashSet};
1304
1305 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1306 model_map
1307 .entry("complex".to_string())
1308 .or_default()
1309 .insert("claude".to_string(), vec!["some-model".to_string()]);
1310
1311 let discovered: HashSet<String> = HashSet::new();
1313
1314 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1315
1316 assert_eq!(pruned, 0, "non-opencode agents must not be touched");
1317 assert_eq!(
1318 model_map["complex"]["claude"].len(),
1319 1,
1320 "claude model should be unchanged"
1321 );
1322 }
1323
1324 #[test]
1325 fn default_config_does_not_use_opencode() {
1326 assert!(!RouterConfig::default().uses_opencode());
1327 }
1328
1329 #[test]
1330 fn uses_opencode_when_a_model_map_tier_lists_it() {
1331 let mut config = RouterConfig::default();
1332 config
1333 .model_map
1334 .entry("simple".to_string())
1335 .or_default()
1336 .insert("opencode".to_string(), vec!["some-model".to_string()]);
1337 assert!(config.uses_opencode());
1338 }
1339
1340 #[test]
1341 fn uses_opencode_follows_the_effective_pool_and_fallback() {
1342 let config = RouterConfig {
1343 pool: vec!["opencode:free".to_string()],
1344 ..RouterConfig::default()
1345 };
1346 assert!(config.uses_opencode());
1347
1348 let config = RouterConfig {
1349 pool: vec!["claude:haiku".to_string()],
1350 ..RouterConfig::default()
1351 };
1352 assert!(!config.uses_opencode());
1353
1354 let config = RouterConfig {
1356 pool: vec![],
1357 router_agent: "opencode".to_string(),
1358 router_model: "free".to_string(),
1359 ..RouterConfig::default()
1360 };
1361 assert!(config.uses_opencode());
1362
1363 let config = RouterConfig {
1364 pool: vec!["claude:haiku".to_string()],
1365 fallback: "opencode:some/model".to_string(),
1366 ..RouterConfig::default()
1367 };
1368 assert!(config.uses_opencode());
1369 }
1370
1371 #[serial(cooldown_state)]
1372 #[test]
1373 fn prune_unavailable_opencode_models_covers_all_complexity_tiers() {
1374 use std::collections::{HashMap, HashSet};
1375
1376 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1377 for tier in ["simple", "medium", "complex", "review"] {
1378 model_map
1379 .entry(tier.to_string())
1380 .or_default()
1381 .insert("opencode".to_string(), vec!["gone-model".to_string()]);
1382 }
1383
1384 let discovered: HashSet<String> = HashSet::new();
1386
1387 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1388
1389 assert_eq!(pruned, 4, "should prune one entry per complexity tier");
1390 for tier in ["simple", "medium", "complex", "review"] {
1391 assert!(
1392 model_map[tier]["opencode"].is_empty(),
1393 "all entries for {tier} should be removed"
1394 );
1395 }
1396 }
1397}