Skip to main content

orch/engine/router/
config.rs

1//! Router configuration — loading, defaults, and model map.
2
3use 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
14/// Model tiers in ascending order
15const COMPLEXITY_TIERS: [&str; 3] = ["simple", "medium", "complex"];
16
17/// Minimum healthy agents threshold for graceful degradation.
18/// When healthy agents fall below this number, dispatch switches to sequential mode.
19pub 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
26/// Base delay in milliseconds between dispatches in sequential (degraded) mode.
27pub 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
34/// Lookback window (in hours) for the pre-emptive agent health check.
35///
36/// The health check queries `rate_limits` for events within this window.
37/// Configurable via `dispatch.health_check_window_hours` (default: 6).
38pub 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
45/// Minimum number of rate-limit events within the health check window to
46/// consider an agent degraded.
47///
48/// A single transient 429 error shouldn't mark an agent degraded; we need
49/// a pattern of repeated failures to detect genuine degradation. This
50/// threshold is compared against the count of rate_limit events in the
51/// `rate_limits` table within the health check window.
52///
53/// IMPORTANT: An agent with `rate_limit_count == 0` is NOT degraded by this
54/// criterion — it must meet or exceed the threshold. The threshold should
55/// be set to a value >= 2 to avoid false positives when there are no events.
56///
57/// Configurable via `dispatch.degraded_rate_limit_threshold` (default: 3).
58pub 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
65/// Base delay in milliseconds for exponential backoff between fallback retries.
66pub 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
73/// Maximum delay in milliseconds for exponential backoff between fallback retries.
74pub 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
81/// Upper bound for router LLM timeout (seconds).
82///
83/// Routing is a short classification step and should fail fast so a single
84/// slow model does not stall fallback to other healthy agents.
85pub const MAX_ROUTER_TIMEOUT_SECS: u64 = 45;
86
87/// With weighted round robin, an agent whose routing weight decays below this
88/// value is skipped during pre-emptive routability checks.
89pub const SKIP_LIMITED_THRESHOLD: f64 = 0.3;
90
91/// Return the configured base backoff (seconds) for an agent.
92///
93/// Returns the value of `router.backoff_base.{agent}` if set, otherwise
94/// the default BACKOFF_BASE_SECS (5 minutes).
95///
96/// This lets opencode and other higher-failure-rate agents use a longer
97/// initial backoff to reduce wasted retry attempts against consistently
98/// failing models.
99pub 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
107/// Maximum number of tasks to route per tick. Prevents blocking the tick loop
108/// when multiple tasks are queued simultaneously (e.g., cron jobs). Each routing
109/// operation involves an LLM call taking 10-45 seconds, so routing N tasks
110/// sequentially would block for N×LLM-latency seconds.
111///
112/// Configurable via `router.max_tasks_per_tick` (default: 1).
113pub 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
120/// Default agents to check in PATH.
121///
122/// All 7 agents are listed, but availability is checked at runtime via
123/// `which::which()`. Agents not installed (e.g. kimi, minimax, glm) are
124/// automatically skipped during routing. Users can customize this list
125/// in their `config.yml` under `agents:`.
126pub const DEFAULT_AGENTS: &[&str] = &[
127    "claude", "codex", "opencode", "kimi", "minimax", "glm", "grok",
128];
129
130/// Router configuration.
131#[derive(Debug, Clone)]
132pub struct RouterConfig {
133    /// Routing mode: "llm", "local", or "round_robin"
134    ///
135    /// - "llm": Use cloud LLMs for routing (default, supports pool-based routing)
136    /// - "local": Use local Ollama model for routing
137    /// - "round_robin": Distribute tasks evenly across available agents
138    pub mode: String,
139    /// Which agent performs routing (default: "claude")
140    pub router_agent: String,
141    /// Model for routing (default: "haiku")
142    pub router_model: String,
143    /// Timeout for routing LLM call in seconds
144    pub timeout_seconds: u64,
145    /// Fallback executor if routing fails
146    pub fallback_executor: String,
147    /// Configurable agent list (checked against PATH at runtime)
148    pub agents: Vec<String>,
149    /// Max LLM routing attempts before falling back to round-robin
150    pub max_route_attempts: u32,
151    /// Default tools allowed
152    pub allowed_tools: Vec<String>,
153    /// Default skills to always include
154    pub default_skills: Vec<String>,
155    /// Model map for complexity levels.
156    /// Each entry is a pool of models; one is selected randomly (skipping cooled ones) per dispatch.
157    /// A single-model pool `vec!["model"]` behaves identically to the old string format.
158    pub model_map: HashMap<String, HashMap<String, Vec<String>>>,
159    /// Enable weighted round-robin routing based on rate limit capacity.
160    /// When true, agents that hit rate limits get fewer tasks.
161    pub weighted_round_robin: bool,
162    /// Pool of `agent:model` entries to round-robin the router LLM call.
163    /// When empty, a single-entry pool is derived from `router_agent:router_model`.
164    /// Special value `opencode:free` is expanded at startup via `opencode models`.
165    pub pool: Vec<String>,
166    /// Fallback `agent:model` when all pool entries are cooled or fail.
167    /// When empty, defaults to `router_agent:router_model`.
168    pub fallback: String,
169    /// Penalty weight applied when the LLM routes a task to itself (the routing agent).
170    ///
171    /// Range: `0.0` (always redirect away from router agent) to `1.0` (no penalty, default).
172    /// When `< 1.0` and the LLM selects its own agent, the router probabilistically
173    /// redirects to another available agent, reducing self-routing bias.
174    ///
175    /// Example: `0.5` means ~50% of self-routed tasks are redirected to another agent.
176    pub self_routing_penalty: f64,
177    /// Configurable base weights per agent.
178    ///
179    /// Higher weight = more tasks routed to this agent. Agents without an explicit
180    /// weight default to 1.0. The weighted selection normalizes these into
181    /// probabilities, so `{claude: 0.6, minimax: 0.03}` means claude gets ~95%
182    /// of tasks when both are available.
183    ///
184    /// Set in `config.yml` under `router.weights`:
185    /// ```yaml
186    /// router:
187    ///   weights:
188    ///     claude: 0.6
189    ///     codex: 0.2
190    ///     opencode: 0.15
191    ///     minimax: 0.03
192    ///     kimi: 0.02
193    /// ```
194    pub weights: HashMap<String, f64>,
195    /// Per-agent base backoff overrides (seconds). Agents not listed use the
196    /// default BACKOFF_BASE_SECS (5 minutes). Configure as `router.backoff_base.{agent}`.
197    ///
198    /// Example: set `router.backoff_base.opencode: 600` for a 10-minute base
199    /// instead of the default 5-minute base (useful for agents with higher
200    /// failure rates like opencode at 24.8%).
201    pub agent_backoff_bases: HashMap<String, i64>,
202    /// Maximum number of tasks to route per tick. Prevents blocking the tick loop
203    /// when multiple tasks are queued simultaneously (e.g., cron jobs). Each routing
204    /// operation involves an LLM call taking 10-45 seconds, so routing N tasks
205    /// sequentially would block for N×LLM-latency seconds.
206    ///
207    /// Configurable via `router.max_tasks_per_tick` (default: 1).
208    pub max_tasks_per_tick: usize,
209    /// Ollama base URL for local routing (default: "http://localhost:11434").
210    pub ollama_url: String,
211    /// Ollama model name for routing (default: "qwen2.5-coder:3b-instruct").
212    pub ollama_model: String,
213    /// Timeout for Ollama HTTP call in seconds (default: 30).
214    pub ollama_timeout_seconds: u64,
215}
216
217/// Parse an `agent:model` pool entry string, splitting on the first colon.
218///
219/// Examples:
220/// - `"claude:haiku"` → `("claude", "haiku")`
221/// - `"opencode:github-copilot/gpt-5-mini"` → `("opencode", "github-copilot/gpt-5-mini")`
222/// - `"claude"` → `("claude", "")`
223pub 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        // Normalize trailing slashes in model part (defense-in-depth for bug #1507)
228        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            // Empty passes no `--model`, so the router agent's CLI uses its own default
241            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 is intentionally empty — config.yml is the sole source of truth.
266            // Hardcoded models cause "Model not found" failures when the model does not
267            // exist for a particular agent (e.g. anthropic/ prefixed models for opencode).
268            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    /// Load configuration from config files.
281    pub fn from_config() -> Self {
282        let mut config = Self::default();
283
284        // Try to load from config
285        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        // Parse agents list from top-level `agents:` config key
328        config.agents = engine::configured_agents();
329
330        // Temporary operational toggle: allow disabling agents without editing
331        // config files. Controlled by ORCH_EXCLUDE_AGENTS env var (comma-separated
332        // list of agent names to exclude from routing). This keeps changes reversible
333        // (unset the env var to restore behavior) and avoids editing user config files.
334        // Example: ORCH_EXCLUDE_AGENTS=glm,some-other-agent
335        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        // Parse allowed_tools as comma-separated or YAML array
363        if let Ok(tools_str) = config::get("router.allowed_tools")
364            && !tools_str.is_empty()
365            && tools_str != "[]"
366        {
367            // Try to parse as JSON/YAML array first
368            if let Ok(tools_arr) = serde_json::from_str::<Vec<String>>(&tools_str) {
369                config.allowed_tools = tools_arr;
370            } else {
371                // Fall back to comma-separated
372                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        // Parse weighted_round_robin
381        if let Ok(val) = config::get("router.weighted_round_robin") {
382            config.weighted_round_robin = val == "true" || val == "1";
383        }
384
385        // Parse self_routing_penalty
386        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        // Parse per-agent weights from router.weights.<agent>
393        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        // Parse per-agent backoff base seconds from router.backoff_base.<agent>
403        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        // Parse max_tasks_per_tick
413        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        // Parse pool: list of "agent:model" entries.
421        // `config::get_list()` returns Ok(vec![]) when the key is missing, so we
422        // can't rely on `is_ok()` to indicate presence.
423        match config::get_list("router.pool") {
424            Ok(pool_list) if !pool_list.is_empty() => {
425                config.pool = pool_list;
426            }
427            _ => {
428                // Accept comma-separated string lists: "a,b,c"
429                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        // Parse fallback: single "agent:model" entry
443        if let Ok(fallback) = config::get("router.fallback")
444            && !fallback.is_empty()
445        {
446            config.fallback = fallback;
447        }
448
449        // Load top-level model shorthand from config (models.{agent}).
450        //
451        // These candidates apply to every complexity tier by default. More
452        // specific model_map.{complexity}.{agent} entries below override the
453        // shorthand for that one tier.
454        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        // Load model_map overrides from config (model_map.{complexity}.{agent})
470        // Value may be a single string ("model-name") or a JSON array (["m1","m2"]).
471        // Iterate over all known complexity tiers regardless of what is in model_map —
472        // the Default impl has no hardcoded entries, so iterating over keys() would be a no-op.
473        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        // Startup config hygiene: warn about and prune opencode model_map entries that
487        // the provider catalog does not currently list.  Running this at load time avoids
488        // dispatching unavailable models during routing — the dynamic filter in
489        // `expanded_model_pool` also catches these, but fails open when discovery returns
490        // empty (e.g. during a background cache refresh), allowing stale models through.
491        // Reading the catalog costs an `opencode models` run, so only configs that can
492        // reach opencode pay for it
493        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        // Parse default_skills
526        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        // Parse Ollama-specific config for local routing mode
542        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    /// Return the effective pool — the configured pool, or a single-entry pool from `router_agent:router_model`.
563    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    /// Return the effective fallback entry — the configured fallback, or `router_agent:router_model`.
572    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    /// Check if a model identifier looks syntactically valid.
581    /// Rejects empty strings, strings ending with slash, and strings with whitespace.
582    fn is_valid_model_identifier(model: &str) -> bool {
583        if model.is_empty() {
584            return false;
585        }
586        // Reject trailing slash (e.g., "opus/")
587        if model.ends_with('/') {
588            return false;
589        }
590        // Reject whitespace
591        if model.chars().any(char::is_whitespace) {
592            return false;
593        }
594        // Ensure that if there is a slash, both parts are non-empty
595        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    /// Normalize a model identifier by stripping trailing slashes.
606    /// This is a defense-in-depth measure to handle edge cases where
607    /// a model string might have been incorrectly formatted.
608    pub fn normalize_model_identifier(model: &str) -> String {
609        model.trim_end_matches('/').to_string()
610    }
611
612    /// The models listed for the agent under `models:` in the config, empty when it has none.
613    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            // Normalize the model identifier first to handle edge cases like trailing slashes
651            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        // Also filter out invalid discovered free models (should be valid but just in case)
666        expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
667
668        // For opencode, filter configured entries against the provider model catalog
669        // when available. This proactively avoids dispatching non-existent model IDs.
670        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    /// True when `complexity` or any adjacent tier has a model out of cooldown, the same search
699    /// `model_for_complexity_or_adjacent` runs
700    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    /// Get the model for a given agent and complexity level.
707    ///
708    /// When the complexity tier has a pool of models, selects randomly using
709    /// `task_id` as an entropy source and skips models currently in cooldown.
710    /// Falls back to `pool[0]` if all models are cooled.
711    ///
712    /// Backward-compatible: a single-model pool always returns that model.
713    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        // Random starting index — varies per task_id to distribute across the pool
724        let start = selection::simple_hash_index_for(expanded_pool.len(), task_id);
725        // Walk the pool from start, skipping cooled and invalid models
726        for i in 0..expanded_pool.len() {
727            let model = &expanded_pool[(start + i) % expanded_pool.len()];
728            // Safety check: skip invalid identifiers (should already be filtered)
729            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                // Normalize the model identifier to handle edge cases like trailing slashes
739                return Some(Self::normalize_model_identifier(model));
740            }
741        }
742        // All models cooled — return None so the caller can fall back to a different agent
743        None
744    }
745
746    /// Tiers to try for `complexity`, in order: its own, the higher tiers nearest first, then the
747    /// lower tiers nearest first. A tier outside simple, medium and complex has only itself
748    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    /// Like `model_for_complexity`, but when the task's own tier has no available model it takes
759    /// one from the tiers above, then below. Returns the model and the tier it came from
760    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    /// The tier `model` runs from for a task of `complexity`: the first tier in the search order
775    /// whose pool lists it, or `complexity` itself when none does
776    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    /// True when this config can reach opencode: a `model_map` tier lists it, or the
788    /// effective router pool or fallback names it. Those are the only entries read out of
789    /// `opencode models`, so every other config can skip the call
790    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    /// Remove `model_map` entries for the `opencode` agent whose identifiers do not
808    /// appear in `discovered`.  The sentinel value `"opencode:free"` is never pruned
809    /// because it is expanded at routing time and is not dispatched as a literal model ID.
810    ///
811    /// Returns the total number of model entries removed across all complexity tiers.
812    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        // Inject a two-model pool for a unique agent name to avoid cross-test pollution
870        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        // Put both models into cooldown
883        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        // Ensure no cooldown is active for this specific model
1006        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        // Simulate olm agent with only simple and medium configured (missing complex and review)
1019        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        // Note: complex and review tiers are NOT configured for olm
1030
1031        // has_available_model_for_complexity should return false when no pool exists
1032        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        // model_map must be empty — config.yml is the sole source of truth for models.
1080        // Hardcoded defaults (especially anthropic/ prefixed opencode models) cause
1081        // "Model not found" failures when the model does not exist for a given agent.
1082        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        // Bug #1507: model assignment was producing "opus/" instead of "opus"
1135        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        // Edge cases: multiple trailing slashes
1151        assert_eq!(
1152            RouterConfig::normalize_model_identifier("opus//"),
1153            "opus",
1154            "multiple trailing slashes should be stripped"
1155        );
1156        // Edge cases: no trailing slash
1157        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        // Ensure validation correctly rejects models with trailing slashes
1168        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        // Simulate a config with a custom agent (olm) in both agents and model_map
1192        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        // olm models should be loaded from the model_map
1216        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        // claude should also work
1227        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        // Bug #1507: full path test - model identifiers with trailing slashes
1337        // should be normalized (not dropped) when loading from model_map pool
1338        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        // Should return normalized "opus" instead of dropping it as invalid
1353        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        // The model returned should be one of the normalized versions
1360        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        // Verify no trailing slashes in the returned model
1365        assert!(
1366            !model.ends_with('/'),
1367            "returned model should not have trailing slash, got: {model}"
1368        );
1369    }
1370
1371    // --- prune_unavailable_opencode_models regression tests (bug #3169) ---
1372
1373    #[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(), // sentinel — must survive pruning
1383            ],
1384        );
1385
1386        let mut discovered = HashSet::new();
1387        discovered.insert("available-model".to_string());
1388        // "unavailable-model" intentionally absent
1389
1390        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        // discovered is empty — irrelevant for non-opencode agents
1443        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        // An empty pool derives its single entry from router_agent:router_model
1486        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        // "gone-model" is not in discovered
1514        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}