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