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