1use std::collections::HashMap;
4use std::collections::HashSet;
5use std::env;
6use std::sync::OnceLock;
7
8use crate::config;
9use crate::engine;
10use crate::engine::cooldown;
11use crate::engine::router::selection;
12use crate::engine::runner::agents::opencode;
13
14const COMPLEXITY_TIERS: [&str; 3] = ["simple", "medium", "complex"];
16
17pub fn min_healthy_agents_threshold() -> usize {
20 config::get("dispatch.min_healthy_agents")
21 .ok()
22 .and_then(|v| v.parse().ok())
23 .unwrap_or(2)
24}
25
26pub fn sequential_dispatch_delay_ms() -> u64 {
28 config::get("dispatch.sequential_delay_ms")
29 .ok()
30 .and_then(|v| v.parse().ok())
31 .unwrap_or(1000)
32}
33
34pub fn health_check_window_hours() -> u32 {
39 config::get("dispatch.health_check_window_hours")
40 .ok()
41 .and_then(|v| v.parse().ok())
42 .unwrap_or(cooldown::DEFAULT_HEALTH_CHECK_WINDOW_HOURS)
43}
44
45pub fn degraded_rate_limit_threshold() -> i64 {
59 config::get("dispatch.degraded_rate_limit_threshold")
60 .ok()
61 .and_then(|v| v.parse().ok())
62 .unwrap_or(cooldown::DEFAULT_DEGRADED_RATE_LIMIT_THRESHOLD)
63}
64
65pub fn retry_base_delay_ms() -> u64 {
67 config::get("dispatch.retry_base_delay_ms")
68 .ok()
69 .and_then(|v| v.parse().ok())
70 .unwrap_or(10_000)
71}
72
73pub fn retry_max_delay_ms() -> u64 {
75 config::get("dispatch.retry_max_delay_ms")
76 .ok()
77 .and_then(|v| v.parse().ok())
78 .unwrap_or(120_000)
79}
80
81pub const MAX_ROUTER_TIMEOUT_SECS: u64 = 45;
86
87pub const SKIP_LIMITED_THRESHOLD: f64 = 0.3;
90
91pub fn get_agent_backoff_base(agent: &str) -> i64 {
100 let key = format!("router.backoff_base.{agent}");
101 config::get(&key)
102 .ok()
103 .and_then(|v| v.parse().ok())
104 .unwrap_or(cooldown::BACKOFF_BASE_SECS)
105}
106
107pub fn max_tasks_per_routing_tick() -> usize {
114 config::get("router.max_tasks_per_tick")
115 .ok()
116 .and_then(|v| v.parse().ok())
117 .unwrap_or(1)
118}
119
120pub const DEFAULT_AGENTS: &[&str] = &[
127 "claude", "codex", "opencode", "kimi", "minimax", "glm", "grok",
128];
129
130#[derive(Debug, Clone)]
132pub struct RouterConfig {
133 pub mode: String,
139 pub router_agent: String,
141 pub router_model: String,
143 pub timeout_seconds: u64,
145 pub fallback_executor: String,
147 pub agents: Vec<String>,
149 pub max_route_attempts: u32,
151 pub allowed_tools: Vec<String>,
153 pub default_skills: Vec<String>,
155 pub model_map: HashMap<String, HashMap<String, Vec<String>>>,
159 pub weighted_round_robin: bool,
162 pub pool: Vec<String>,
166 pub fallback: String,
169 pub self_routing_penalty: f64,
177 pub weights: HashMap<String, f64>,
195 pub agent_backoff_bases: HashMap<String, i64>,
202 pub max_tasks_per_tick: usize,
209 pub ollama_url: String,
211 pub ollama_model: String,
213 pub ollama_timeout_seconds: u64,
215}
216
217pub fn parse_pool_entry(entry: &str) -> (String, String) {
224 if let Some(colon_pos) = entry.find(':') {
225 let agent = entry[..colon_pos].to_string();
226 let model = entry[colon_pos + 1..].to_string();
227 let model = RouterConfig::normalize_model_identifier(&model);
229 (agent, model)
230 } else {
231 (entry.to_string(), String::new())
232 }
233}
234
235impl Default for RouterConfig {
236 fn default() -> Self {
237 Self {
238 mode: "round_robin".to_string(),
239 router_agent: "claude".to_string(),
240 router_model: "haiku".to_string(),
241 timeout_seconds: 45,
242 fallback_executor: "codex".to_string(),
243 agents: DEFAULT_AGENTS.iter().map(ToString::to_string).collect(),
244 max_route_attempts: 3,
245 pool: vec![],
246 fallback: String::new(),
247 self_routing_penalty: 1.0,
248 weights: HashMap::new(),
249 allowed_tools: vec![
250 "yq".to_string(),
251 "jq".to_string(),
252 "bash".to_string(),
253 "just".to_string(),
254 "git".to_string(),
255 "rg".to_string(),
256 "sed".to_string(),
257 "awk".to_string(),
258 "python3".to_string(),
259 "node".to_string(),
260 "npm".to_string(),
261 "bun".to_string(),
262 ],
263 default_skills: vec!["gh".to_string(), "git-worktree".to_string()],
264 model_map: HashMap::new(),
268 weighted_round_robin: false,
269 agent_backoff_bases: HashMap::new(),
270 max_tasks_per_tick: 1,
271 ollama_url: "http://localhost:11434".to_string(),
272 ollama_model: "qwen2.5-coder:3b-instruct".to_string(),
273 ollama_timeout_seconds: 30,
274 }
275 }
276}
277
278impl RouterConfig {
279 pub fn from_config() -> Self {
281 let mut config = Self::default();
282
283 if let Ok(mode) = config::get("router.mode")
285 && (mode == "round_robin" || mode == "llm" || mode == "local")
286 {
287 config.mode = mode;
288 }
289
290 if let Ok(agent) = config::get("router.agent")
291 && !agent.is_empty()
292 {
293 config.router_agent = agent;
294 }
295
296 if let Ok(model) = config::get("router.model")
297 && !model.is_empty()
298 {
299 config.router_model = RouterConfig::normalize_model_identifier(&model);
300 }
301
302 if let Ok(timeout) = config::get("router.timeout_seconds")
303 && let Ok(secs) = timeout.parse::<u64>()
304 {
305 let clamped = secs.min(MAX_ROUTER_TIMEOUT_SECS);
306 if clamped != secs {
307 static TIMEOUT_CLAMP_WARNED: OnceLock<()> = OnceLock::new();
308 TIMEOUT_CLAMP_WARNED.get_or_init(|| {
309 tracing::warn!(
310 configured_secs = secs,
311 applied_secs = clamped,
312 max_secs = MAX_ROUTER_TIMEOUT_SECS,
313 "router.timeout_seconds is too high; clamping to keep routing responsive"
314 );
315 });
316 }
317 config.timeout_seconds = clamped;
318 }
319
320 if let Ok(fallback) = config::get("router.fallback_executor")
321 && !fallback.is_empty()
322 {
323 config.fallback_executor = fallback;
324 }
325
326 config.agents = engine::configured_agents();
328
329 let exclude_agents = env::var("ORCH_EXCLUDE_AGENTS")
335 .map(|v| {
336 v.split(',')
337 .map(|s| s.trim().to_string())
338 .filter(|s| !s.is_empty())
339 .collect::<Vec<_>>()
340 })
341 .unwrap_or_default();
342 if !exclude_agents.is_empty() {
343 let original_len = config.agents.len();
344 config.agents.retain(|a| !exclude_agents.contains(a));
345 let removed_count = original_len - config.agents.len();
346 if removed_count > 0 {
347 tracing::warn!(
348 "ORCH_EXCLUDE_AGENTS={}: temporarily removing {} agent(s) from router",
349 exclude_agents.join(","),
350 removed_count
351 );
352 }
353 }
354
355 if let Ok(max_attempts) = config::get("router.max_route_attempts")
356 && let Ok(n) = max_attempts.parse::<u32>()
357 {
358 config.max_route_attempts = n;
359 }
360
361 if let Ok(tools_str) = config::get("router.allowed_tools")
363 && !tools_str.is_empty()
364 && tools_str != "[]"
365 {
366 if let Ok(tools_arr) = serde_json::from_str::<Vec<String>>(&tools_str) {
368 config.allowed_tools = tools_arr;
369 } else {
370 config.allowed_tools = tools_str
372 .split(',')
373 .map(|s| s.trim().to_string())
374 .filter(|s| !s.is_empty())
375 .collect();
376 }
377 }
378
379 if let Ok(val) = config::get("router.weighted_round_robin") {
381 config.weighted_round_robin = val == "true" || val == "1";
382 }
383
384 if let Ok(val) = config::get("router.self_routing_penalty")
386 && let Ok(penalty) = val.parse::<f64>()
387 {
388 config.self_routing_penalty = penalty.clamp(0.0, 1.0);
389 }
390
391 for agent in &config.agents {
393 let key = format!("router.weights.{agent}");
394 if let Ok(val) = config::get(&key)
395 && let Ok(w) = val.parse::<f64>()
396 {
397 config.weights.insert(agent.clone(), w.max(0.0));
398 }
399 }
400
401 for agent in &config.agents {
403 let key = format!("router.backoff_base.{agent}");
404 if let Ok(val) = config::get(&key)
405 && let Ok(secs) = val.parse::<i64>()
406 {
407 config.agent_backoff_bases.insert(agent.clone(), secs);
408 }
409 }
410
411 if let Ok(val) = config::get("router.max_tasks_per_tick")
413 && let Ok(n) = val.parse::<usize>()
414 && n > 0
415 {
416 config.max_tasks_per_tick = n;
417 }
418
419 match config::get_list("router.pool") {
423 Ok(pool_list) if !pool_list.is_empty() => {
424 config.pool = pool_list;
425 }
426 _ => {
427 if let Ok(pool_raw) = config::get("router.pool") {
429 let parsed = pool_raw
430 .split(',')
431 .map(|s| s.trim().to_string())
432 .filter(|s| !s.is_empty())
433 .collect::<Vec<_>>();
434 if !parsed.is_empty() {
435 config.pool = parsed;
436 }
437 }
438 }
439 }
440
441 if let Ok(fallback) = config::get("router.fallback")
443 && !fallback.is_empty()
444 {
445 config.fallback = fallback;
446 }
447
448 let known_agents = engine::configured_agents();
454 let known_complexities = ["simple", "medium", "complex", "review"];
455 for agent in &known_agents {
456 let key = format!("models.{agent}");
457 if let Some(models) = Self::read_model_pool_from_config(&key) {
458 for complexity in known_complexities {
459 config
460 .model_map
461 .entry(complexity.to_string())
462 .or_default()
463 .insert(agent.clone(), models.clone());
464 }
465 }
466 }
467
468 for complexity in known_complexities {
473 for agent in &known_agents {
474 let key = format!("model_map.{complexity}.{agent}");
475 if let Some(models) = Self::read_model_pool_from_config(&key) {
476 config
477 .model_map
478 .entry(complexity.to_string())
479 .or_default()
480 .insert(agent.clone(), models);
481 }
482 }
483 }
484
485 let discovered_opencode = if config.uses_opencode() {
493 opencode::discover_opencode_models()
494 } else {
495 Vec::new()
496 };
497 if !discovered_opencode.is_empty() {
498 let discovered: HashSet<String> = discovered_opencode.into_iter().collect();
499 for complexity in known_complexities {
500 if let Some(agent_models) = config.model_map.get(complexity)
501 && let Some(models) = agent_models.get("opencode")
502 {
503 for model in models {
504 if model != "opencode:free" && !discovered.contains(model) {
505 tracing::warn!(
506 complexity,
507 model,
508 "opencode model from config not present in provider model list; pruning from dispatch pool"
509 );
510 }
511 }
512 }
513 }
514 let pruned =
515 Self::prune_unavailable_opencode_models(&mut config.model_map, &discovered);
516 if pruned > 0 {
517 tracing::info!(
518 pruned_count = pruned,
519 "model_pruned_unavailable: removed opencode models absent from provider catalog"
520 );
521 }
522 }
523
524 if let Ok(skills_str) = config::get("router.default_skills")
526 && !skills_str.is_empty()
527 && skills_str != "[]"
528 {
529 if let Ok(skills_arr) = serde_json::from_str::<Vec<String>>(&skills_str) {
530 config.default_skills = skills_arr;
531 } else {
532 config.default_skills = skills_str
533 .split(',')
534 .map(|s| s.trim().to_string())
535 .filter(|s| !s.is_empty())
536 .collect();
537 }
538 }
539
540 if let Ok(url) = config::get("router.ollama_url")
542 && !url.is_empty()
543 {
544 config.ollama_url = url;
545 }
546 if let Ok(model) = config::get("router.ollama_model")
547 && !model.is_empty()
548 {
549 config.ollama_model = model;
550 }
551 if let Ok(timeout) = config::get("router.ollama_timeout_seconds")
552 && let Ok(secs) = timeout.parse::<u64>()
553 && secs > 0
554 {
555 config.ollama_timeout_seconds = secs;
556 }
557
558 config
559 }
560
561 pub fn effective_pool(&self) -> Vec<String> {
563 if self.pool.is_empty() {
564 vec![format!("{}:{}", self.router_agent, self.router_model)]
565 } else {
566 self.pool.clone()
567 }
568 }
569
570 pub fn effective_fallback(&self) -> String {
572 if self.fallback.is_empty() {
573 format!("{}:{}", self.router_agent, self.router_model)
574 } else {
575 self.fallback.clone()
576 }
577 }
578
579 fn is_valid_model_identifier(model: &str) -> bool {
582 if model.is_empty() {
583 return false;
584 }
585 if model.ends_with('/') {
587 return false;
588 }
589 if model.chars().any(char::is_whitespace) {
591 return false;
592 }
593 if let Some(slash_pos) = model.find('/') {
595 let before = &model[..slash_pos];
596 let after = &model[slash_pos + 1..];
597 if before.is_empty() || after.is_empty() {
598 return false;
599 }
600 }
601 true
602 }
603
604 pub fn normalize_model_identifier(model: &str) -> String {
608 model.trim_end_matches('/').to_string()
609 }
610
611 fn read_model_pool_from_config(key: &str) -> Option<Vec<String>> {
612 match config::get_list(key) {
613 Ok(list) => {
614 if !list.is_empty() {
615 return Some(
616 list.iter()
617 .map(|m| RouterConfig::normalize_model_identifier(m))
618 .collect(),
619 );
620 }
621 }
622 _ => {
623 if let Ok(val) = config::get(key)
624 && !val.is_empty()
625 {
626 return Some(vec![RouterConfig::normalize_model_identifier(&val)]);
627 }
628 }
629 }
630
631 None
632 }
633
634 fn expanded_model_pool(&self, agent: &str, complexity: &str) -> Option<Vec<String>> {
635 let pool = self.model_map.get(complexity)?.get(agent)?;
636 if pool.is_empty() {
637 return Some(Vec::new());
638 }
639
640 let mut expanded_pool = Vec::new();
641 let mut has_free = false;
642
643 for model in pool {
644 let normalized = Self::normalize_model_identifier(model);
646 if normalized == "opencode:free" {
647 has_free = true;
648 } else if Self::is_valid_model_identifier(&normalized) {
649 expanded_pool.push(normalized);
650 } else {
651 tracing::debug!(agent, model, "skipping invalid model identifier in config");
652 }
653 }
654
655 if has_free {
656 expanded_pool.extend(opencode::discover_free_opencode_models());
657 }
658
659 expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
661
662 if agent == "opencode" {
665 let discovered = opencode::discover_opencode_models();
666 if !discovered.is_empty() {
667 let discovered_set: HashSet<String> = discovered.into_iter().collect();
668 expanded_pool.retain(|m| discovered_set.contains(m));
669 }
670 }
671
672 Some(expanded_pool)
673 }
674
675 pub(crate) fn model_pool_for_complexity(
676 &self,
677 agent: &str,
678 complexity: &str,
679 ) -> Option<Vec<String>> {
680 self.expanded_model_pool(agent, complexity)
681 }
682
683 pub fn has_available_model_for_complexity(&self, agent: &str, complexity: &str) -> bool {
684 match self.expanded_model_pool(agent, complexity) {
685 None => false,
686 Some(pool) => pool
687 .iter()
688 .any(|model| !cooldown::is_model_in_cooldown(agent, model)),
689 }
690 }
691
692 pub fn has_available_model_in_any_tier(&self, agent: &str, complexity: &str) -> bool {
695 Self::tier_search_order(complexity)
696 .into_iter()
697 .any(|tier| self.has_available_model_for_complexity(agent, tier))
698 }
699
700 pub fn model_for_complexity(
708 &self,
709 agent: &str,
710 complexity: &str,
711 task_id: &str,
712 ) -> Option<String> {
713 let expanded_pool = self.expanded_model_pool(agent, complexity)?;
714 if expanded_pool.is_empty() {
715 return None;
716 }
717 let start = selection::simple_hash_index_for(expanded_pool.len(), task_id);
719 for i in 0..expanded_pool.len() {
721 let model = &expanded_pool[(start + i) % expanded_pool.len()];
722 if !Self::is_valid_model_identifier(model) {
724 tracing::debug!(
725 agent,
726 model,
727 "invalid model identifier slipped through validation"
728 );
729 continue;
730 }
731 if !cooldown::is_model_in_cooldown(agent, model) {
732 return Some(Self::normalize_model_identifier(model));
734 }
735 }
736 None
738 }
739
740 fn tier_search_order(complexity: &str) -> Vec<&str> {
743 let Some(idx) = COMPLEXITY_TIERS.iter().position(|t| *t == complexity) else {
744 return vec![complexity];
745 };
746 let mut order = vec![COMPLEXITY_TIERS[idx]];
747 order.extend(&COMPLEXITY_TIERS[idx + 1..]);
748 order.extend(COMPLEXITY_TIERS[..idx].iter().rev());
749 order
750 }
751
752 pub fn model_for_complexity_or_adjacent(
755 &self,
756 agent: &str,
757 complexity: &str,
758 task_id: &str,
759 ) -> Option<(String, String)> {
760 Self::tier_search_order(complexity)
761 .into_iter()
762 .find_map(|tier| {
763 self.model_for_complexity(agent, tier, task_id)
764 .map(|model| (model, tier.to_string()))
765 })
766 }
767
768 pub fn tier_of_model(&self, agent: &str, model: &str, complexity: &str) -> String {
771 Self::tier_search_order(complexity)
772 .into_iter()
773 .find(|tier| {
774 self.expanded_model_pool(agent, tier)
775 .is_some_and(|pool| pool.iter().any(|m| m == model))
776 })
777 .unwrap_or(complexity)
778 .to_string()
779 }
780
781 pub(crate) fn uses_opencode(&self) -> bool {
785 fn names_opencode(entry: &str) -> bool {
786 entry == "opencode" || entry.starts_with("opencode:")
787 }
788
789 if self
790 .model_map
791 .values()
792 .any(|by_agent| by_agent.contains_key("opencode"))
793 {
794 return true;
795 }
796
797 self.effective_pool().iter().any(|e| names_opencode(e))
798 || names_opencode(&self.effective_fallback())
799 }
800
801 fn prune_unavailable_opencode_models(
807 model_map: &mut HashMap<String, HashMap<String, Vec<String>>>,
808 discovered: &HashSet<String>,
809 ) -> usize {
810 let mut pruned = 0;
811 for agent_models in model_map.values_mut() {
812 if let Some(models) = agent_models.get_mut("opencode") {
813 let before = models.len();
814 models.retain(|m| m == "opencode:free" || discovered.contains(m));
815 pruned += before - models.len();
816 }
817 }
818 pruned
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use super::{MAX_ROUTER_TIMEOUT_SECS, RouterConfig};
825 use crate::engine::cooldown;
826 use serial_test::serial;
827 use std::collections::HashMap;
828 use std::collections::HashSet;
829 use std::env;
830 use std::fs;
831 use std::path::Path;
832 use std::path::PathBuf;
833 use std::sync::{Mutex, OnceLock};
834
835 struct CurrentDirGuard {
836 original: PathBuf,
837 }
838
839 impl CurrentDirGuard {
840 fn set(path: &Path) -> Self {
841 let original = env::current_dir().unwrap();
842 env::set_current_dir(path).unwrap();
843 Self { original }
844 }
845 }
846
847 impl Drop for CurrentDirGuard {
848 fn drop(&mut self) {
849 env::set_current_dir(&self.original).unwrap();
850 }
851 }
852
853 fn cwd_mutex() -> &'static Mutex<()> {
854 static CWD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
855 CWD_MUTEX.get_or_init(|| Mutex::new(()))
856 }
857
858 #[serial(cooldown_state)]
859 #[tokio::test]
860 async fn model_for_complexity_returns_none_when_all_models_cooled() {
861 cooldown::reset_global_state().await;
862 let mut config = RouterConfig::default();
863 config
865 .model_map
866 .entry("complex".to_string())
867 .or_default()
868 .insert(
869 "testagent_allcooled".to_string(),
870 vec![
871 "cooldown-model-a".to_string(),
872 "cooldown-model-b".to_string(),
873 ],
874 );
875
876 cooldown::record_model_failure("testagent_allcooled", "cooldown-model-a").await;
878 cooldown::record_model_failure("testagent_allcooled", "cooldown-model-b").await;
879
880 let result = config.model_for_complexity("testagent_allcooled", "complex", "task-0");
881 assert!(
882 result.is_none(),
883 "should return None when all models in pool are in cooldown, got {result:?}"
884 );
885 }
886
887 fn tiered_config(agent: &str, tiers: &[(&str, &str)]) -> RouterConfig {
888 let mut config = RouterConfig::default();
889 config.model_map.clear();
890 for (tier, model) in tiers {
891 config
892 .model_map
893 .entry((*tier).to_string())
894 .or_default()
895 .insert(agent.to_string(), vec![(*model).to_string()]);
896 }
897 config
898 }
899
900 #[serial(cooldown_state)]
901 #[tokio::test]
902 async fn adjacent_tier_prefers_higher_over_lower() {
903 cooldown::reset_global_state().await;
904 let agent = "tieragent_higher";
905 let config = tiered_config(
906 agent,
907 &[("simple", "t-s"), ("medium", "t-m"), ("complex", "t-c")],
908 );
909 cooldown::record_model_failure(agent, "t-m").await;
910
911 let got = config.model_for_complexity_or_adjacent(agent, "medium", "t");
912 assert_eq!(got, Some(("t-c".to_string(), "complex".to_string())));
913 assert_eq!(config.tier_of_model(agent, "t-c", "medium"), "complex");
914 }
915
916 #[serial(cooldown_state)]
917 #[tokio::test]
918 async fn simple_task_tries_medium_then_complex_before_lower() {
919 cooldown::reset_global_state().await;
920 let agent = "tieragent_simple";
921 let config = tiered_config(
922 agent,
923 &[("simple", "u-s"), ("medium", "u-m"), ("complex", "u-c")],
924 );
925 cooldown::record_model_failure(agent, "u-s").await;
926 cooldown::record_model_failure(agent, "u-m").await;
927
928 let got = config.model_for_complexity_or_adjacent(agent, "simple", "t");
929 assert_eq!(got, Some(("u-c".to_string(), "complex".to_string())));
930 }
931
932 #[serial(cooldown_state)]
933 #[tokio::test]
934 async fn adjacent_tier_falls_back_to_lower_when_higher_tiers_are_unavailable() {
935 cooldown::reset_global_state().await;
936 let agent = "tieragent_lower";
937 let config = tiered_config(agent, &[("simple", "v-s"), ("medium", "v-m")]);
938 cooldown::record_model_failure(agent, "v-m").await;
939
940 let got = config.model_for_complexity_or_adjacent(agent, "complex", "t");
941 assert_eq!(got, Some(("v-s".to_string(), "simple".to_string())));
942 }
943
944 #[serial(cooldown_state)]
945 #[tokio::test]
946 async fn adjacent_tier_returns_none_when_every_tier_is_cooled() {
947 cooldown::reset_global_state().await;
948 let agent = "tieragent_allcooled";
949 let config = tiered_config(
950 agent,
951 &[("simple", "w-s"), ("medium", "w-m"), ("complex", "w-c")],
952 );
953 for m in ["w-s", "w-m", "w-c"] {
954 cooldown::record_model_failure(agent, m).await;
955 }
956
957 assert_eq!(
958 config.model_for_complexity_or_adjacent(agent, "medium", "t"),
959 None
960 );
961 }
962
963 #[serial(cooldown_state)]
964 #[tokio::test]
965 async fn model_for_complexity_returns_none_when_single_model_cooled() {
966 cooldown::reset_global_state().await;
967 let mut config = RouterConfig::default();
968 config
969 .model_map
970 .entry("complex".to_string())
971 .or_default()
972 .insert(
973 "testagent_singlecooled".to_string(),
974 vec!["single-cooled-model".to_string()],
975 );
976
977 cooldown::record_model_failure("testagent_singlecooled", "single-cooled-model").await;
978
979 let result = config.model_for_complexity("testagent_singlecooled", "complex", "task-0");
980 assert!(
981 result.is_none(),
982 "should return None when the only model in pool is in cooldown, got {result:?}"
983 );
984 }
985
986 #[serial(cooldown_state)]
987 #[test]
988 fn model_for_complexity_returns_model_when_not_cooled() {
989 let mut config = RouterConfig::default();
990 config
991 .model_map
992 .entry("complex".to_string())
993 .or_default()
994 .insert(
995 "testagent_notcooled".to_string(),
996 vec!["healthy-model".to_string()],
997 );
998
999 let result = config.model_for_complexity("testagent_notcooled", "complex", "task-0");
1001 assert_eq!(
1002 result.as_deref(),
1003 Some("healthy-model"),
1004 "should return the model when it is not in cooldown"
1005 );
1006 }
1007
1008 #[serial(cooldown_state)]
1009 #[test]
1010 fn has_available_model_returns_false_when_no_pool_configured() {
1011 let mut config = RouterConfig::default();
1012 config
1014 .model_map
1015 .entry("simple".to_string())
1016 .or_default()
1017 .insert("olm".to_string(), vec!["qwen3.5".to_string()]);
1018 config
1019 .model_map
1020 .entry("medium".to_string())
1021 .or_default()
1022 .insert("olm".to_string(), vec!["gemma4".to_string()]);
1023 assert!(
1027 config.has_available_model_for_complexity("olm", "simple"),
1028 "should return true when model pool exists and is configured"
1029 );
1030 assert!(
1031 config.has_available_model_for_complexity("olm", "medium"),
1032 "should return true when model pool exists and is configured"
1033 );
1034 assert!(
1035 !config.has_available_model_for_complexity("olm", "complex"),
1036 "should return false when no model pool is configured for agent+complexity"
1037 );
1038 assert!(
1039 !config.has_available_model_for_complexity("olm", "review"),
1040 "should return false when no model pool is configured for agent+complexity"
1041 );
1042 }
1043
1044 #[serial(cooldown_state)]
1045 #[test]
1046 fn default_self_routing_penalty_is_one() {
1047 let config = RouterConfig::default();
1048 assert!(
1049 (config.self_routing_penalty - 1.0).abs() < f64::EPSILON,
1050 "default penalty must be 1.0 (no penalty = full self-routing allowed)"
1051 );
1052 }
1053
1054 #[serial(cooldown_state)]
1055 #[test]
1056 fn from_config_reads_self_routing_penalty() {
1057 let _lock = cwd_mutex().lock().unwrap();
1058 let dir = tempfile::tempdir().unwrap();
1059 fs::write(
1060 dir.path().join(".orch.yml"),
1061 "router:\n self_routing_penalty: 0.5\n",
1062 )
1063 .unwrap();
1064 let _guard = CurrentDirGuard::set(dir.path());
1065
1066 let config = RouterConfig::from_config();
1067 assert!((config.self_routing_penalty - 0.5).abs() < f64::EPSILON);
1068 }
1069
1070 #[serial(cooldown_state)]
1071 #[test]
1072 fn default_config_model_map_is_empty() {
1073 let config = RouterConfig::default();
1077 assert!(
1078 config.model_map.is_empty(),
1079 "Default RouterConfig must have no hardcoded models; got {:?}",
1080 config.model_map
1081 );
1082 }
1083
1084 #[serial(cooldown_state)]
1085 #[test]
1086 fn from_config_reads_router_pool_yaml_array() {
1087 let _lock = cwd_mutex().lock().unwrap();
1088 let dir = tempfile::tempdir().unwrap();
1089 fs::write(
1090 dir.path().join(".orch.yml"),
1091 "router:\n pool:\n - opencode:free\n - opencode:github-copilot/gpt-5-mini\n - kimi:k2p5\n - claude:haiku\n",
1092 )
1093 .unwrap();
1094 let _guard = CurrentDirGuard::set(dir.path());
1095
1096 let config = RouterConfig::from_config();
1097
1098 assert_eq!(
1099 config.pool,
1100 vec![
1101 "opencode:free",
1102 "opencode:github-copilot/gpt-5-mini",
1103 "kimi:k2p5",
1104 "claude:haiku",
1105 ]
1106 );
1107 }
1108
1109 #[serial(cooldown_state)]
1110 #[test]
1111 fn from_config_clamps_router_timeout_to_max() {
1112 let _lock = cwd_mutex().lock().unwrap();
1113 let dir = tempfile::tempdir().unwrap();
1114 fs::write(
1115 dir.path().join(".orch.yml"),
1116 "router:\n timeout_seconds: 90\n",
1117 )
1118 .unwrap();
1119 let _guard = CurrentDirGuard::set(dir.path());
1120
1121 let config = RouterConfig::from_config();
1122 assert_eq!(config.timeout_seconds, MAX_ROUTER_TIMEOUT_SECS);
1123 }
1124
1125 #[serial(cooldown_state)]
1126 #[test]
1127 fn normalize_model_identifier_strips_trailing_slash() {
1128 assert_eq!(
1130 RouterConfig::normalize_model_identifier("opus/"),
1131 "opus",
1132 "trailing slash should be stripped from model identifier"
1133 );
1134 assert_eq!(
1135 RouterConfig::normalize_model_identifier("sonnet/"),
1136 "sonnet",
1137 "trailing slash should be stripped from model identifier"
1138 );
1139 assert_eq!(
1140 RouterConfig::normalize_model_identifier("github-copilot/gpt-5-mini/"),
1141 "github-copilot/gpt-5-mini",
1142 "trailing slash should be stripped from model identifier with internal slash"
1143 );
1144 assert_eq!(
1146 RouterConfig::normalize_model_identifier("opus//"),
1147 "opus",
1148 "multiple trailing slashes should be stripped"
1149 );
1150 assert_eq!(
1152 RouterConfig::normalize_model_identifier("opus"),
1153 "opus",
1154 "model without trailing slash should remain unchanged"
1155 );
1156 }
1157
1158 #[serial(cooldown_state)]
1159 #[test]
1160 fn is_valid_model_identifier_rejects_trailing_slash() {
1161 assert!(
1163 !RouterConfig::is_valid_model_identifier("opus/"),
1164 "model with trailing slash should be invalid"
1165 );
1166 assert!(
1167 RouterConfig::is_valid_model_identifier("opus"),
1168 "model without trailing slash should be valid"
1169 );
1170 assert!(
1171 RouterConfig::is_valid_model_identifier("github-copilot/gpt-5-mini"),
1172 "model with internal slash should be valid"
1173 );
1174 assert!(
1175 !RouterConfig::is_valid_model_identifier("github-copilot/"),
1176 "model ending with slash after prefix should be invalid"
1177 );
1178 }
1179
1180 #[serial(cooldown_state)]
1181 #[test]
1182 fn from_config_loads_model_map_for_custom_agents() {
1183 let _lock = cwd_mutex().lock().unwrap();
1184 let dir = tempfile::tempdir().unwrap();
1185 fs::write(
1187 dir.path().join(".orch.yml"),
1188 r"
1189agents:
1190 - claude
1191 - olm
1192model_map:
1193 simple:
1194 claude: haiku
1195 olm: qwen3.5
1196 medium:
1197 claude: sonnet
1198 olm: gemma4
1199 complex:
1200 claude: opus
1201 olm: gemma4
1202",
1203 )
1204 .unwrap();
1205 let _guard = CurrentDirGuard::set(dir.path());
1206
1207 let config = RouterConfig::from_config();
1208
1209 assert_eq!(
1211 config.model_for_complexity("olm", "simple", "test-task"),
1212 Some("qwen3.5".to_string()),
1213 "custom agent olm should have simple model from config"
1214 );
1215 assert_eq!(
1216 config.model_for_complexity("olm", "medium", "test-task"),
1217 Some("gemma4".to_string()),
1218 "custom agent olm should have medium model from config"
1219 );
1220 assert_eq!(
1222 config.model_for_complexity("claude", "simple", "test-task"),
1223 Some("haiku".to_string()),
1224 );
1225 }
1226
1227 #[serial(cooldown_state)]
1228 #[test]
1229 fn from_config_loads_models_shorthand_for_all_complexities() {
1230 let _lock = cwd_mutex().lock().unwrap();
1231 let dir = tempfile::tempdir().unwrap();
1232 fs::write(
1233 dir.path().join(".orch.yml"),
1234 r"
1235agents:
1236 - shorthand
1237models:
1238 shorthand:
1239 - gpt-5.2
1240 - gpt-5.5
1241",
1242 )
1243 .unwrap();
1244 let _guard = CurrentDirGuard::set(dir.path());
1245
1246 let config = RouterConfig::from_config();
1247
1248 for complexity in ["simple", "medium", "complex", "review"] {
1249 assert_eq!(
1250 config.model_map[complexity]["shorthand"],
1251 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()],
1252 "models.shorthand should populate {complexity}"
1253 );
1254 }
1255 }
1256
1257 #[serial(cooldown_state)]
1258 #[test]
1259 fn from_config_loads_single_model_shorthand_for_all_complexities() {
1260 let _lock = cwd_mutex().lock().unwrap();
1261 let dir = tempfile::tempdir().unwrap();
1262 fs::write(
1263 dir.path().join(".orch.yml"),
1264 r"
1265agents:
1266 - shorthand
1267models:
1268 shorthand: gpt-5.2
1269",
1270 )
1271 .unwrap();
1272 let _guard = CurrentDirGuard::set(dir.path());
1273
1274 let config = RouterConfig::from_config();
1275
1276 for complexity in ["simple", "medium", "complex", "review"] {
1277 assert_eq!(
1278 config.model_map[complexity]["shorthand"],
1279 vec!["gpt-5.2".to_string()],
1280 "models.shorthand should populate {complexity}"
1281 );
1282 }
1283 }
1284
1285 #[serial(cooldown_state)]
1286 #[test]
1287 fn from_config_model_map_overrides_models_shorthand_per_tier() {
1288 let _lock = cwd_mutex().lock().unwrap();
1289 let dir = tempfile::tempdir().unwrap();
1290 fs::write(
1291 dir.path().join(".orch.yml"),
1292 r"
1293agents:
1294 - shorthand
1295models:
1296 shorthand:
1297 - gpt-5.2
1298 - gpt-5.5
1299model_map:
1300 review:
1301 shorthand: gpt-5.6-review
1302",
1303 )
1304 .unwrap();
1305 let _guard = CurrentDirGuard::set(dir.path());
1306
1307 let config = RouterConfig::from_config();
1308
1309 assert_eq!(
1310 config.model_map["simple"]["shorthand"],
1311 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1312 );
1313 assert_eq!(
1314 config.model_map["medium"]["shorthand"],
1315 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1316 );
1317 assert_eq!(
1318 config.model_map["complex"]["shorthand"],
1319 vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1320 );
1321 assert_eq!(
1322 config.model_map["review"]["shorthand"],
1323 vec!["gpt-5.6-review".to_string()]
1324 );
1325 }
1326
1327 #[serial(cooldown_state)]
1328 #[test]
1329 fn model_for_complexity_normalizes_trailing_slash_from_pool() {
1330 let mut config = RouterConfig::default();
1333 config
1334 .model_map
1335 .entry("complex".to_string())
1336 .or_default()
1337 .insert(
1338 "testagent_trailing".to_string(),
1339 vec![
1340 "opus/".to_string(),
1341 "sonnet//".to_string(),
1342 "github-copilot/gpt-5-mini/".to_string(),
1343 ],
1344 );
1345
1346 let result = config.model_for_complexity("testagent_trailing", "complex", "task-0");
1348 assert!(
1349 result.is_some(),
1350 "should return a model from pool with trailing slashes normalized, got None"
1351 );
1352 let model = result.unwrap();
1353 assert!(
1355 model == "opus" || model == "sonnet" || model == "github-copilot/gpt-5-mini",
1356 "returned model should be normalized (no trailing slash), got: {model}"
1357 );
1358 assert!(
1360 !model.ends_with('/'),
1361 "returned model should not have trailing slash, got: {model}"
1362 );
1363 }
1364
1365 #[serial(cooldown_state)]
1368 #[test]
1369 fn prune_unavailable_opencode_models_removes_missing_models() {
1370 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1371 model_map.entry("complex".to_string()).or_default().insert(
1372 "opencode".to_string(),
1373 vec![
1374 "available-model".to_string(),
1375 "unavailable-model".to_string(),
1376 "opencode:free".to_string(), ],
1378 );
1379
1380 let mut discovered = HashSet::new();
1381 discovered.insert("available-model".to_string());
1382 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1385
1386 assert_eq!(pruned, 1, "should prune exactly the one unavailable model");
1387
1388 let remaining = model_map["complex"]["opencode"].as_slice();
1389 assert!(
1390 remaining.contains(&"available-model".to_string()),
1391 "available model should remain"
1392 );
1393 assert!(
1394 remaining.contains(&"opencode:free".to_string()),
1395 "opencode:free sentinel should never be pruned"
1396 );
1397 assert!(
1398 !remaining.contains(&"unavailable-model".to_string()),
1399 "unavailable model should be removed"
1400 );
1401 }
1402
1403 #[serial(cooldown_state)]
1404 #[test]
1405 fn prune_unavailable_opencode_models_noop_when_all_available() {
1406 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1407 model_map.entry("simple".to_string()).or_default().insert(
1408 "opencode".to_string(),
1409 vec!["model-a".to_string(), "model-b".to_string()],
1410 );
1411
1412 let discovered: HashSet<String> = ["model-a", "model-b"]
1413 .iter()
1414 .map(ToString::to_string)
1415 .collect();
1416
1417 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1418
1419 assert_eq!(pruned, 0, "nothing to prune when all models are available");
1420 assert_eq!(
1421 model_map["simple"]["opencode"].len(),
1422 2,
1423 "both models should remain"
1424 );
1425 }
1426
1427 #[serial(cooldown_state)]
1428 #[test]
1429 fn prune_unavailable_opencode_models_preserves_non_opencode_agents() {
1430 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1431 model_map
1432 .entry("complex".to_string())
1433 .or_default()
1434 .insert("claude".to_string(), vec!["some-model".to_string()]);
1435
1436 let discovered: HashSet<String> = HashSet::new();
1438
1439 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1440
1441 assert_eq!(pruned, 0, "non-opencode agents must not be touched");
1442 assert_eq!(
1443 model_map["complex"]["claude"].len(),
1444 1,
1445 "claude model should be unchanged"
1446 );
1447 }
1448
1449 #[test]
1450 fn default_config_does_not_use_opencode() {
1451 assert!(!RouterConfig::default().uses_opencode());
1452 }
1453
1454 #[test]
1455 fn uses_opencode_when_a_model_map_tier_lists_it() {
1456 let mut config = RouterConfig::default();
1457 config
1458 .model_map
1459 .entry("simple".to_string())
1460 .or_default()
1461 .insert("opencode".to_string(), vec!["some-model".to_string()]);
1462 assert!(config.uses_opencode());
1463 }
1464
1465 #[test]
1466 fn uses_opencode_follows_the_effective_pool_and_fallback() {
1467 let config = RouterConfig {
1468 pool: vec!["opencode:free".to_string()],
1469 ..RouterConfig::default()
1470 };
1471 assert!(config.uses_opencode());
1472
1473 let config = RouterConfig {
1474 pool: vec!["claude:haiku".to_string()],
1475 ..RouterConfig::default()
1476 };
1477 assert!(!config.uses_opencode());
1478
1479 let config = RouterConfig {
1481 pool: vec![],
1482 router_agent: "opencode".to_string(),
1483 router_model: "free".to_string(),
1484 ..RouterConfig::default()
1485 };
1486 assert!(config.uses_opencode());
1487
1488 let config = RouterConfig {
1489 pool: vec!["claude:haiku".to_string()],
1490 fallback: "opencode:some/model".to_string(),
1491 ..RouterConfig::default()
1492 };
1493 assert!(config.uses_opencode());
1494 }
1495
1496 #[serial(cooldown_state)]
1497 #[test]
1498 fn prune_unavailable_opencode_models_covers_all_complexity_tiers() {
1499 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1500 for tier in ["simple", "medium", "complex", "review"] {
1501 model_map
1502 .entry(tier.to_string())
1503 .or_default()
1504 .insert("opencode".to_string(), vec!["gone-model".to_string()]);
1505 }
1506
1507 let discovered: HashSet<String> = HashSet::new();
1509
1510 let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1511
1512 assert_eq!(pruned, 4, "should prune one entry per complexity tier");
1513 for tier in ["simple", "medium", "complex", "review"] {
1514 assert!(
1515 model_map[tier]["opencode"].is_empty(),
1516 "all entries for {tier} should be removed"
1517 );
1518 }
1519 }
1520}