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